@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,159 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { HourBand, HourBandSkeleton } from "./hour-band";
3
+ import { EventCard } from "./event-card";
4
+ import type { EventCardLabels, EventFormatters, EventPreview } from "./event-types";
5
+
6
+ const labels: EventCardLabels = { free: "Free", liveNow: "Live now" };
7
+
8
+ const formatters: EventFormatters = {
9
+ time: () => "20:00",
10
+ day: () => "Thu, Sep 3",
11
+ price: (amount) => `${amount} RON`,
12
+ };
13
+
14
+ const event = (id: string, title: string, place: string, fee?: number): EventPreview => ({
15
+ id,
16
+ title,
17
+ location: place,
18
+ place_name: place,
19
+ starts_at: "2026-09-03T20:00:00",
20
+ entry_fee: fee,
21
+ });
22
+
23
+ const meta: Meta<typeof HourBand> = {
24
+ title: "Events/HourBand",
25
+ component: HourBand,
26
+ tags: ["autodocs"],
27
+ decorators: [
28
+ (Story) => (
29
+ <div className="w-[390px] bg-background px-4">
30
+ <Story />
31
+ </div>
32
+ ),
33
+ ],
34
+ parameters: {
35
+ docs: {
36
+ description: {
37
+ component:
38
+ "One hour of the timeline: a 52px gutter with a hairline and an hour puck, and that hour's events beside it. The cards inside carry no timestamp of their own — the band is the timestamp, which is why they are rendered with `hideWhen`.",
39
+ },
40
+ },
41
+ },
42
+ };
43
+
44
+ export default meta;
45
+ type Story = StoryObj<typeof HourBand>;
46
+
47
+ export const Default: Story = {
48
+ args: {
49
+ time: "21:00",
50
+ children: (
51
+ <>
52
+ <EventCard
53
+ event={event("1", "Karaoke Night", "VIBE OffSeason")}
54
+ labels={labels}
55
+ formatters={formatters}
56
+ hideWhen
57
+ />
58
+ <EventCard
59
+ event={event("2", "Urban Sounds at Caro", "Caro", 0)}
60
+ labels={labels}
61
+ formatters={formatters}
62
+ hideWhen
63
+ />
64
+ </>
65
+ ),
66
+ },
67
+ };
68
+
69
+ /** The live hour: green-bordered puck, NOW label, and the content column shifted to clear it. */
70
+ export const Live: Story = {
71
+ args: {
72
+ time: "20:00",
73
+ isLive: true,
74
+ liveLabel: "NOW ON",
75
+ children: (
76
+ <EventCard
77
+ event={event("3", "Psihotrop — From the Beginning to Infinity", "Atelier Café", 50)}
78
+ labels={labels}
79
+ formatters={formatters}
80
+ hideWhen
81
+ liveStatus="Live now · ends around 23:00"
82
+ />
83
+ ),
84
+ },
85
+ };
86
+
87
+ /** A single event in a quiet hour. */
88
+ export const OneEvent: Story = {
89
+ args: {
90
+ time: "19:00",
91
+ children: (
92
+ <EventCard
93
+ event={event("4", "Quiz Night", "Flying Circus")}
94
+ labels={labels}
95
+ formatters={formatters}
96
+ hideWhen
97
+ />
98
+ ),
99
+ },
100
+ };
101
+
102
+ /** Consecutive bands — the hairline should read as one continuous axis. */
103
+ export const StackedHours: Story = {
104
+ render: () => (
105
+ <>
106
+ <HourBand time="20:00" isLive liveLabel="NOW ON">
107
+ <EventCard
108
+ event={event("5", "Psihotrop Live", "Atelier Café", 50)}
109
+ labels={labels}
110
+ formatters={formatters}
111
+ hideWhen
112
+ liveStatus="Live now · ends around 23:00"
113
+ />
114
+ </HourBand>
115
+ <HourBand time="21:00">
116
+ <EventCard
117
+ event={event("6", "Charity Concert & Afterparty", "Flying Circus", 30)}
118
+ labels={labels}
119
+ formatters={formatters}
120
+ hideWhen
121
+ />
122
+ </HourBand>
123
+ <HourBand time="22:00">
124
+ <EventCard
125
+ event={event("7", "Schoolotheque Afterparty with Machedon", "Flying Circus", 25)}
126
+ labels={labels}
127
+ formatters={formatters}
128
+ hideWhen
129
+ />
130
+ </HourBand>
131
+ </>
132
+ ),
133
+ };
134
+
135
+ /**
136
+ * Loading. The puck is omitted rather than blocked out — the hour isn't known
137
+ * until data lands, and a grey lozenge sitting on the axis reads as a real but
138
+ * unreadable timestamp. The hairline alone still says "time axis".
139
+ */
140
+ export const Loading: Story = {
141
+ render: () => <HourBandSkeleton />,
142
+ };
143
+
144
+ /** Loading beside loaded, so gutter and card geometry drift is visible at a glance. */
145
+ export const LoadingVsLoaded: Story = {
146
+ render: () => (
147
+ <div className="grid grid-cols-2 gap-6">
148
+ <HourBandSkeleton cardCount={1} />
149
+ <HourBand time="21:00">
150
+ <EventCard
151
+ event={event("8", "Karaoke Night", "VIBE OffSeason")}
152
+ labels={labels}
153
+ formatters={formatters}
154
+ hideWhen
155
+ />
156
+ </HourBand>
157
+ </div>
158
+ ),
159
+ };
@@ -0,0 +1,126 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cn } from "../lib/utils";
5
+ import { Separator } from "./separator";
6
+ import { Skeleton } from "./skeleton";
7
+ import { TimelineHourPuck } from "./timeline-band";
8
+
9
+ /**
10
+ * HourBand — one hour on the timeline's axis, with that hour's events beside it.
11
+ *
12
+ * The design's mobile timeline is deliberately plain: a 52px gutter carrying a
13
+ * hairline and an hour puck, and full-width cards to its right. Nothing else.
14
+ * The cards inside carry no timestamp of their own — this band IS the
15
+ * timestamp, which is why `EventCard` has `hideWhen`.
16
+ *
17
+ * The puck is the shared `TimelineHourPuck`, sitting ON the line with a
18
+ * background fill punching through it so the hairline reads as continuous
19
+ * behind the label. It used to be a second, hand-rolled copy of the desktop
20
+ * band's puck, and the two had drifted apart — this one borderless and
21
+ * `rounded-md px-1.5`, the desktop one bordered and `rounded-[7px] px-[7px]`.
22
+ * Sharing the object changes this gutter's pixels: the mobile puck now carries
23
+ * a border, which is what makes it read as clipped onto the rail rather than
24
+ * text floating over it, and matches the station on the desktop spine.
25
+ *
26
+ * `isLive` turns the puck green-bordered and reveals the NOW label beneath it,
27
+ * which is why live bands need extra top padding on the content column — the
28
+ * label occupies space the cards would otherwise start in.
29
+ *
30
+ * ── Why the gap is 24px and not 12px ──────────────────────────────────────
31
+ * THE PUCK IS WIDER THAN THE GUTTER IT SITS IN, and the gap is what absorbs
32
+ * the difference. A 12-hour locale renders "09:00 AM" — 8 monospace glyphs at
33
+ * 12.5px (~60px) plus `px-[7px]` twice and a border twice, so ~76px of puck
34
+ * centred on a 52px gutter, overflowing ~12px on each side. At the original
35
+ * `gap-3` that right-hand overflow exactly consumed the gap and the puck's
36
+ * edge landed ON the content column, which read as the hairline colliding
37
+ * with the rows beside it.
38
+ *
39
+ * 24px leaves ~12px of real clearance at the widest label. Do not tighten it
40
+ * back without re-deriving against a 12-hour locale: a 24-hour one ("09:00")
41
+ * is ~30px narrower and hides the problem completely.
42
+ */
43
+
44
+ export type HourBandProps = {
45
+ /** The hour, already formatted and localised — "20:00". */
46
+ time: string;
47
+ /** Marks this as the hour currently in progress. */
48
+ isLive?: boolean;
49
+ /** Shown under the puck when live, e.g. "NOW ON". Required to render it. */
50
+ liveLabel?: string;
51
+ /** The hour's events. */
52
+ children: React.ReactNode;
53
+ className?: string;
54
+ };
55
+
56
+ export function HourBand({ time, isLive, liveLabel, children, className }: HourBandProps) {
57
+ const showLiveLabel = isLive && !!liveLabel;
58
+
59
+ return (
60
+ <div className={cn("flex gap-6 pt-[18px]", className)}>
61
+ <div className="relative flex w-[52px] shrink-0 justify-center">
62
+ <Separator orientation="vertical" className="h-full" />
63
+ <TimelineHourPuck
64
+ time={time}
65
+ isLive={isLive}
66
+ liveLabel={liveLabel}
67
+ className="absolute left-1/2 top-0 w-0"
68
+ />
69
+ </div>
70
+
71
+ <div
72
+ className={cn(
73
+ "flex min-w-0 flex-1 flex-col gap-5 pb-2",
74
+ showLiveLabel ? "pt-[34px]" : "pt-1"
75
+ )}
76
+ >
77
+ {children}
78
+ </div>
79
+ </div>
80
+ );
81
+ }
82
+
83
+ export type HourBandSkeletonProps = {
84
+ /** Cards to reserve space for. The design's loading frame shows two. */
85
+ cardCount?: number;
86
+ className?: string;
87
+ };
88
+
89
+ /**
90
+ * The mobile timeline's loading state, mirroring `HourBand` exactly — same 52px
91
+ * gutter, same hairline, same content gap — so nothing jumps when data lands.
92
+ *
93
+ * The puck is omitted rather than blocked out: the hour is not known until the
94
+ * data arrives, and a grey lozenge on the axis reads as a real, unreadable
95
+ * timestamp. The hairline alone still says "this is a time axis".
96
+ *
97
+ * Card footprints come from the design's own loading frame: a 220px poster with
98
+ * an 16px title bar and a 12px meta bar, then a 170px poster with a 15px title.
99
+ */
100
+ export function HourBandSkeleton({ cardCount = 2, className }: HourBandSkeletonProps) {
101
+ const cards = [
102
+ { poster: 220, title: "80%", meta: "50%" },
103
+ { poster: 170, title: "70%", meta: null },
104
+ ];
105
+
106
+ return (
107
+ <div className={cn("flex gap-6 pt-[18px]", className)} aria-hidden="true">
108
+ <div className="relative flex w-[52px] shrink-0 justify-center">
109
+ <Separator orientation="vertical" className="h-full" />
110
+ </div>
111
+
112
+ <div className="flex min-w-0 flex-1 flex-col gap-5 pb-2 pt-1">
113
+ {Array.from({ length: cardCount }).map((_, index) => {
114
+ const card = cards[index % cards.length];
115
+ return (
116
+ <div key={index} className="flex flex-col gap-2.5">
117
+ <Skeleton className="w-full" style={{ height: card.poster }} />
118
+ <Skeleton className="h-4" style={{ width: card.title }} />
119
+ {card.meta && <Skeleton className="h-3" style={{ width: card.meta }} />}
120
+ </div>
121
+ );
122
+ })}
123
+ </div>
124
+ </div>
125
+ );
126
+ }
@@ -0,0 +1,100 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { HourGroup } from "./hour-group";
3
+ import { EventCompactRow } from "./event-compact-row";
4
+ import type { EventCardLabels, EventFormatters, EventPreview } from "./event-types";
5
+
6
+ const labels: EventCardLabels = { free: "Free", liveNow: "Live now" };
7
+
8
+ const formatters: EventFormatters = {
9
+ time: (iso) => new Date(iso).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }),
10
+ day: () => "Thu, Sep 3",
11
+ price: (amount) => `${amount} RON`,
12
+ };
13
+
14
+ const event = (id: string, title: string, place: string, startsAt: string, fee?: number): EventPreview => ({
15
+ id,
16
+ title,
17
+ location: place,
18
+ place_name: place,
19
+ starts_at: startsAt,
20
+ entry_fee: fee,
21
+ });
22
+
23
+ const meta: Meta<typeof HourGroup> = {
24
+ title: "Events/HourGroup",
25
+ component: HourGroup,
26
+ tags: ["autodocs"],
27
+ args: { time: "21:00", countLabel: "3 events" },
28
+ decorators: [
29
+ (Story) => (
30
+ <div className="w-[420px]">
31
+ <Story />
32
+ </div>
33
+ ),
34
+ ],
35
+ parameters: {
36
+ docs: {
37
+ description: {
38
+ component:
39
+ "An hour heading inside `<TimelineBand>`'s content column — a time, a rule, and a count — with that hour's rows beneath and an optional trailing note.",
40
+ },
41
+ },
42
+ },
43
+ };
44
+
45
+ export default meta;
46
+ type Story = StoryObj<typeof HourGroup>;
47
+
48
+ export const Default: Story = {
49
+ args: {
50
+ children: (
51
+ <>
52
+ <EventCompactRow
53
+ event={event("1", "Urban Sounds at Caro", "Caro", "2026-09-03T21:00:00", 0)}
54
+ labels={labels}
55
+ formatters={formatters}
56
+ />
57
+ <EventCompactRow
58
+ event={event("2", "Quiz Night", "Flying Circus", "2026-09-03T21:30:00")}
59
+ labels={labels}
60
+ formatters={formatters}
61
+ />
62
+ </>
63
+ ),
64
+ },
65
+ };
66
+
67
+ /** A trailing note after the rows, e.g. summarising overflow. */
68
+ export const WithNote: Story = {
69
+ args: {
70
+ countLabel: "5 events",
71
+ note: "3 more not shown.",
72
+ children: (
73
+ <EventCompactRow
74
+ event={event("3", "COJO", "MATTER", "2026-09-03T21:00:00", 40)}
75
+ labels={labels}
76
+ formatters={formatters}
77
+ />
78
+ ),
79
+ },
80
+ };
81
+
82
+ /** No rows — a quiet hour still gets its heading. */
83
+ export const Empty: Story = {
84
+ args: { countLabel: "0 events" },
85
+ };
86
+
87
+ /** The mobile variant — a half-step smaller, matching `HourBand`'s own top offset. */
88
+ export const Small: Story = {
89
+ args: {
90
+ size: "sm",
91
+ children: (
92
+ <EventCompactRow
93
+ size="sm"
94
+ event={event("4", "Quiz Night", "Flying Circus", "2026-09-03T21:00:00")}
95
+ labels={labels}
96
+ formatters={formatters}
97
+ />
98
+ ),
99
+ },
100
+ };
@@ -0,0 +1,124 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cn } from "../lib/utils";
5
+ import { Separator } from "./separator";
6
+ import { TimelineHourPuck } from "./timeline-band";
7
+
8
+ /**
9
+ * HourGroup — an hour heading inside `<TimelineBand>`'s content column: a
10
+ * time, a rule, and a count, with that hour's rows beneath and an optional
11
+ * trailing note (e.g. "3 more not shown").
12
+ *
13
+ * ── `spine` — the desktop default ────────────────────────────────────────
14
+ * Inside a `<TimelineBand>`, pass `spine` and the hour renders as a
15
+ * `TimelineHourPuck` out ON the axis, centred on this heading's own rule,
16
+ * instead of as inline text at the head of the content column. Every hour of
17
+ * the day then reads as a station on the same rail, which is the entire point
18
+ * of the rail; the band itself no longer draws any hour, so there is nothing
19
+ * left to collide with.
20
+ *
21
+ * The offset is `calc(var(--timeline-spine) * -0.5)` — half the gutter, back
22
+ * from the content column's left edge, which lands on the hairline. Written
23
+ * with `*-0.5` rather than `/2` because a slash inside a Tailwind arbitrary
24
+ * value collides with the opacity-modifier parse. The `w-0` wrapper plus the
25
+ * puck's own `justify-center` is what centres it, so no `-translate-x-1/2` is
26
+ * needed and the live label centres on the same line for free.
27
+ *
28
+ * `spine` implies the inline time is suppressed — no caller should have to
29
+ * pass `spine hideTime` to avoid printing the hour twice.
30
+ *
31
+ * Keep the rule and the count. The rule is the only thing tying a puck to the
32
+ * rows it labels across the empty gutter; without it the puck reads as a
33
+ * sticker rather than a station on a track. The count carries information
34
+ * present nowhere else and terminates the rule so it doesn't dissolve.
35
+ *
36
+ * Where a group's rows already carry their own timestamps, `hideTime` drops the
37
+ * time and keeps the rule and count, so the hour is stated exactly once.
38
+ *
39
+ * `size="sm"` is the mobile variant, shrinking the type a half-step and
40
+ * matching `<HourBand>`'s own top offset (18px) rather than the desktop
41
+ * default (22px).
42
+ */
43
+
44
+ export type HourGroupProps = {
45
+ /** The hour, already formatted and localised — "20:00". */
46
+ time: string;
47
+ /**
48
+ * Render the hour as a puck out on `<TimelineBand>`'s spine instead of as
49
+ * inline text. The desktop timeline's default; implies `hideTime`.
50
+ */
51
+ spine?: boolean;
52
+ /** Marks this hour as the one currently in progress. `spine` only. */
53
+ isLive?: boolean;
54
+ /** Shown under the puck when live, e.g. "NOW". `spine` only. */
55
+ liveLabel?: string;
56
+ /**
57
+ * Drop the leading time, keeping the rule and count. For groups whose rows
58
+ * state their own time.
59
+ */
60
+ hideTime?: boolean;
61
+ /** Caller-composed, e.g. "2 events" — mirrors `EventDayHeader`'s `meta`. */
62
+ countLabel: string;
63
+ /** Trailing note shown after the rows, e.g. "3 more not shown". */
64
+ note?: string;
65
+ /** The hour's rows. */
66
+ children?: React.ReactNode;
67
+ size?: "sm" | "default";
68
+ className?: string;
69
+ };
70
+
71
+ export function HourGroup({
72
+ time,
73
+ spine,
74
+ isLive,
75
+ liveLabel,
76
+ hideTime,
77
+ countLabel,
78
+ note,
79
+ children,
80
+ size = "default",
81
+ className,
82
+ }: HourGroupProps) {
83
+ const isSm = size === "sm";
84
+
85
+ return (
86
+ // The group's top padding lives HERE, not on the heading row, and that is
87
+ // load-bearing for `spine`: `top-1/2` resolves against the heading row's
88
+ // PADDING box while `items-center` centres the rule in its CONTENT box, so
89
+ // with `pt-[22px]` on the row the puck landed 19.25px down and the rule at
90
+ // 30.25px — 11px apart, the puck visibly riding above the line it is
91
+ // supposed to sit on. Unpadded, both resolve against the same box and the
92
+ // puck's centre, the rule and the spine hairline are one line.
93
+ <div className={cn(isSm ? "pt-[18px]" : "pt-[22px]", className)}>
94
+ <div className={cn("flex items-center gap-2.5", spine && "relative")}>
95
+ {spine && (
96
+ <TimelineHourPuck
97
+ time={time}
98
+ isLive={isLive}
99
+ liveLabel={liveLabel}
100
+ className="absolute left-[calc(var(--timeline-spine)*-0.5)] top-1/2 w-0 -translate-y-1/2"
101
+ />
102
+ )}
103
+ {!hideTime && !spine && (
104
+ <span
105
+ className={cn(
106
+ "font-mono text-muted-foreground tabular-nums",
107
+ isSm ? "text-[11.5px]" : "text-xs"
108
+ )}
109
+ >
110
+ {time}
111
+ </span>
112
+ )}
113
+ <Separator className="flex-1" />
114
+ <span className={cn("text-muted-foreground/70", isSm ? "text-[10.5px]" : "text-[11px]")}>
115
+ {countLabel}
116
+ </span>
117
+ </div>
118
+
119
+ {children}
120
+
121
+ {note && <p className="pt-[9px] text-[11.5px] text-muted-foreground">{note}</p>}
122
+ </div>
123
+ );
124
+ }
@@ -0,0 +1,57 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Icon — the static iconography contract.
5
+ *
6
+ * - `strokeWidth` 1.75, locking the global stroke to a refined-but-robust weight
7
+ * - Three canonical sizes:
8
+ * "xs" → 12px inline with small text, filter pills, badge labels
9
+ * "sm" → 16px default: buttons, nav rows, card metadata
10
+ * "md" → 20px nav tabs, dialog headers
11
+ *
12
+ * Usage:
13
+ * import { Icon } from "@bearmenu/ui/components/icon";
14
+ * <Icon icon={Star} size="sm" className="text-primary fill-primary" aria-hidden />
15
+ *
16
+ * Raw lucide imports remain acceptable where all three of strokeWidth, sizing
17
+ * and colouring are intentionally non-standard (an illustrated 48px empty
18
+ * state, a map marker). In those cases the caller sets `strokeWidth` itself.
19
+ *
20
+ * For icons that perform a gesture on interaction, see `motion-icon.tsx`.
21
+ * Which glyph means what is recorded in `lib/icon-registry.ts`.
22
+ */
23
+
24
+ import { type LucideIcon, type LucideProps } from "lucide-react";
25
+
26
+ import { cn } from "../lib/utils";
27
+
28
+ const SIZE_CLASSES = {
29
+ xs: "w-3 h-3",
30
+ sm: "w-4 h-4",
31
+ md: "w-5 h-5",
32
+ } as const;
33
+
34
+ export type IconSize = keyof typeof SIZE_CLASSES;
35
+
36
+ export type IconProps = Omit<LucideProps, "size"> & {
37
+ /** The lucide icon component to render. */
38
+ icon: LucideIcon;
39
+ /** Canonical size stop. "xs" = 12px | "sm" = 16px (default) | "md" = 20px */
40
+ size?: IconSize;
41
+ };
42
+
43
+ export function Icon({
44
+ icon: LucideIconComponent,
45
+ size = "sm",
46
+ strokeWidth = 1.75,
47
+ className,
48
+ ...props
49
+ }: IconProps) {
50
+ return (
51
+ <LucideIconComponent
52
+ strokeWidth={strokeWidth}
53
+ className={cn(SIZE_CLASSES[size], className)}
54
+ {...props}
55
+ />
56
+ );
57
+ }
@@ -8,7 +8,7 @@ const meta = {
8
8
  } satisfies Meta<typeof InputOTP>;
9
9
 
10
10
  export default meta;
11
- type Story = StoryObj<typeof meta>;
11
+ type Story = StoryObj<typeof InputOTP>;
12
12
 
13
13
  export const Default: Story = {
14
14
  render: () => (
@@ -8,7 +8,7 @@ const meta = {
8
8
  } satisfies Meta<typeof MarkdownRenderer>;
9
9
 
10
10
  export default meta;
11
- type Story = StoryObj<typeof meta>;
11
+ type Story = StoryObj<typeof MarkdownRenderer>;
12
12
 
13
13
  export const Default: Story = {
14
14
  render: () => (
@@ -0,0 +1,78 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { render } from "@testing-library/react";
3
+ import * as React from "react";
4
+
5
+ /**
6
+ * First-paint correctness.
7
+ *
8
+ * `MotionIcon` poses itself with `controls.set()` inside an effect, which runs
9
+ * AFTER the first paint. Any sub-part whose rest pose is hidden therefore needs
10
+ * a matching static SVG attribute, or it paints one frame fully visible — a
11
+ * flash of a drawn check on every unselected row of a selection list, or of a
12
+ * moon behind a sun.
13
+ *
14
+ * The mock renders variants inertly, so what these assertions see is exactly
15
+ * what the browser paints before framer takes over.
16
+ */
17
+ vi.mock("framer-motion", () => {
18
+ const passthrough =
19
+ (tag: string) =>
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
21
+ ({ variants: _v, style: _s, ...rest }: any) =>
22
+ React.createElement(tag, rest);
23
+ return {
24
+ useAnimationControls: () => ({ set: () => {}, start: () => Promise.resolve() }),
25
+ motion: {
26
+ svg: passthrough("svg"),
27
+ g: passthrough("g"),
28
+ path: passthrough("path"),
29
+ circle: passthrough("circle"),
30
+ rect: passthrough("rect"),
31
+ },
32
+ };
33
+ });
34
+
35
+ import {
36
+ MotionCheck,
37
+ MotionEye,
38
+ MotionCopy,
39
+ MotionTheme,
40
+ MotionSearch,
41
+ MotionPlayPause,
42
+ MotionHeart,
43
+ MotionBookmark,
44
+ MotionStar,
45
+ } from "./motion-icon-controls";
46
+
47
+ /** Every element that should be invisible until the icon becomes active. */
48
+ const HIDDEN_AT_REST: Array<[string, React.ComponentType<Record<string, never>>, number]> = [
49
+ ["MotionCheck", MotionCheck, 1],
50
+ ["MotionEye", MotionEye, 1],
51
+ ["MotionCopy", MotionCopy, 1],
52
+ ["MotionTheme", MotionTheme, 1],
53
+ ["MotionSearch", MotionSearch, 1],
54
+ ["MotionPlayPause", MotionPlayPause, 2],
55
+ ];
56
+
57
+ describe("first paint, before the pose effect runs", () => {
58
+ it.each(HIDDEN_AT_REST)(
59
+ "%s hides its active-only parts with a static attribute",
60
+ (_name, Icon, expectedHidden) => {
61
+ const { container } = render(<Icon />);
62
+ const hidden = container.querySelectorAll('[opacity="0"]');
63
+ expect(hidden.length).toBe(expectedHidden);
64
+ }
65
+ );
66
+
67
+ it.each([
68
+ ["MotionHeart", MotionHeart],
69
+ ["MotionBookmark", MotionBookmark],
70
+ ["MotionStar", MotionStar],
71
+ ] as Array<[string, React.ComponentType<Record<string, never>>]>)(
72
+ "%s starts unfilled rather than solid",
73
+ (_name, Icon) => {
74
+ const { container } = render(<Icon />);
75
+ expect(container.querySelector('[fill-opacity="0"]')).not.toBeNull();
76
+ }
77
+ );
78
+ });