@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,322 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { ChevronRight } from "lucide-react";
5
+ import { MotionPlayPause } from "./motion-icon-controls";
6
+ import { cn } from "../lib/utils";
7
+ import { Skeleton } from "./skeleton";
8
+
9
+ /**
10
+ * WeekendPanel — one day panel in the weekend section: a header row over an
11
+ * auto-advancing two-up carousel of the day's event cards.
12
+ *
13
+ * The carousel assumes exactly two "pages" of two cards (four `children` in
14
+ * total) — that's what the design's slide keyframes measure against
15
+ * (`translateX(calc(-100% - gap))` moves the track exactly one page). Pass a
16
+ * different count and the maths still animate, but the pause points won't
17
+ * line up with a full page.
18
+ *
19
+ * ACCESSIBILITY: satisfies WCAG 2.2.2 (Pause, Stop, Hide) with a visible
20
+ * pause/play toggle next to the progress dots. Hover/focus/off-screen pausing
21
+ * remain as convenience behaviour, but the button is the actual mechanism —
22
+ * it is a latch (`isManuallyPaused`), not a hold, so a touch or low-vision
23
+ * user can stop the carousel and keep it stopped while they read. Toggling it
24
+ * flips `animationPlayState` for the track AND both dot keyframes together.
25
+ * Under `prefers-reduced-motion` the panel already renders a static grid with
26
+ * nothing animating, so the control would be meaningless and is not rendered.
27
+ */
28
+
29
+ const WEEKEND_PANEL_TRACK_GAP_PX = 14;
30
+ const WEEKEND_PANEL_DEFAULT_DURATION_SECONDS = 7;
31
+ const WEEKEND_PANEL_SLIDE_KEYFRAMES_ID = "ds-weekend-panel-slide";
32
+ const WEEKEND_PANEL_DOT_A_KEYFRAMES_ID = "ds-weekend-panel-dot-a";
33
+ const WEEKEND_PANEL_DOT_B_KEYFRAMES_ID = "ds-weekend-panel-dot-b";
34
+
35
+ function usePrefersReducedMotion(): boolean {
36
+ const [reduced, setReduced] = React.useState(false);
37
+
38
+ React.useEffect(() => {
39
+ if (typeof window === "undefined" || !window.matchMedia) return;
40
+ const query = window.matchMedia("(prefers-reduced-motion: reduce)");
41
+ setReduced(query.matches);
42
+ const onChange = () => setReduced(query.matches);
43
+ query.addEventListener("change", onChange);
44
+ return () => query.removeEventListener("change", onChange);
45
+ }, []);
46
+
47
+ return reduced;
48
+ }
49
+
50
+ function useInViewport<T extends Element>(): [React.RefObject<T | null>, boolean] {
51
+ const ref = React.useRef<T | null>(null);
52
+ const [inView, setInView] = React.useState(true);
53
+
54
+ React.useEffect(() => {
55
+ const node = ref.current;
56
+ if (!node || typeof IntersectionObserver === "undefined") return;
57
+ const observer = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting), {
58
+ threshold: 0,
59
+ });
60
+ observer.observe(node);
61
+ return () => observer.disconnect();
62
+ }, []);
63
+
64
+ return [ref, inView];
65
+ }
66
+
67
+ export type WeekendPanelLinkProps = {
68
+ href: string;
69
+ className?: string;
70
+ children: React.ReactNode;
71
+ };
72
+
73
+ function DefaultLink({ href, ...props }: WeekendPanelLinkProps) {
74
+ return <a href={href} {...props} />;
75
+ }
76
+
77
+ /** Accessible names for the pause/play toggle, injected so the DS owns no copy. */
78
+ export type WeekendPanelLabels = {
79
+ /** Name announced while the carousel is running (the button pauses it). */
80
+ pause: string;
81
+ /** Name announced while the carousel is paused (the button resumes it). */
82
+ play: string;
83
+ };
84
+
85
+ export type WeekendPanelProps = {
86
+ dayLabel: string;
87
+ /** Caller-composed, e.g. "23 events". */
88
+ countLabel: string;
89
+ actionLabel: string;
90
+ actionHref?: string;
91
+ linkComponent?: React.ComponentType<WeekendPanelLinkProps>;
92
+ onActionClick?: () => void;
93
+ isHighlighted?: boolean;
94
+ /** Loop duration for the slide/dots animation. Default 7 — sibling panels typically stagger at 8.5 and 10. */
95
+ durationSeconds?: number;
96
+ /**
97
+ * Accessible names for the pause/play toggle. Required — the control is
98
+ * only omitted under `prefers-reduced-motion`, where the labels go unused.
99
+ */
100
+ labels: WeekendPanelLabels;
101
+ /** The event cards. The animation is built around exactly four (two pages of two). */
102
+ children?: React.ReactNode;
103
+ className?: string;
104
+ };
105
+
106
+ export function WeekendPanel({
107
+ dayLabel,
108
+ countLabel,
109
+ actionLabel,
110
+ actionHref,
111
+ linkComponent: LinkComponent = DefaultLink,
112
+ onActionClick,
113
+ isHighlighted,
114
+ durationSeconds = WEEKEND_PANEL_DEFAULT_DURATION_SECONDS,
115
+ labels,
116
+ children,
117
+ className,
118
+ }: WeekendPanelProps) {
119
+ const prefersReducedMotion = usePrefersReducedMotion();
120
+ const [panelRef, inView] = useInViewport<HTMLDivElement>();
121
+ const [isHovered, setIsHovered] = React.useState(false);
122
+ const [isFocused, setIsFocused] = React.useState(false);
123
+ const [isManuallyPaused, setIsManuallyPaused] = React.useState(false);
124
+ const [playPauseReplayToken, setPlayPauseReplayToken] = React.useState(0);
125
+ const isPaused = isManuallyPaused || isHovered || isFocused || !inView;
126
+
127
+ const action = (
128
+ <span className="ml-auto flex items-center gap-0.5 text-[12.5px] text-muted-foreground">
129
+ {actionLabel}
130
+ <ChevronRight className="h-3.5 w-3.5" aria-hidden />
131
+ </span>
132
+ );
133
+
134
+ return (
135
+ <div
136
+ ref={panelRef}
137
+ onMouseEnter={() => setIsHovered(true)}
138
+ onMouseLeave={() => setIsHovered(false)}
139
+ onFocus={() => setIsFocused(true)}
140
+ onBlur={() => setIsFocused(false)}
141
+ className={cn(
142
+ "relative overflow-hidden rounded-2xl border bg-card py-4",
143
+ isHighlighted ? "border-primary/40" : "border-border",
144
+ className
145
+ )}
146
+ >
147
+ <style>{`
148
+ @keyframes ${WEEKEND_PANEL_SLIDE_KEYFRAMES_ID} {
149
+ 0%, 45% { transform: translateX(0); }
150
+ 50%, 95% { transform: translateX(calc(-100% - ${WEEKEND_PANEL_TRACK_GAP_PX}px)); }
151
+ 100% { transform: translateX(0); }
152
+ }
153
+ @keyframes ${WEEKEND_PANEL_DOT_A_KEYFRAMES_ID} {
154
+ 0% { background: var(--primary); width: 18px; }
155
+ 45% { background: var(--primary); width: 18px; }
156
+ 50% { background: color-mix(in oklch, var(--muted-foreground) 35%, transparent); width: 6px; }
157
+ 100% { background: color-mix(in oklch, var(--muted-foreground) 35%, transparent); width: 6px; }
158
+ }
159
+ @keyframes ${WEEKEND_PANEL_DOT_B_KEYFRAMES_ID} {
160
+ 0% { background: color-mix(in oklch, var(--muted-foreground) 35%, transparent); width: 6px; }
161
+ 50% { background: color-mix(in oklch, var(--muted-foreground) 35%, transparent); width: 6px; }
162
+ 95% { background: var(--primary); width: 18px; }
163
+ 100% { background: var(--primary); width: 18px; }
164
+ }
165
+ `}</style>
166
+
167
+ <div className="flex items-baseline gap-[9px] px-4 pb-3">
168
+ <span
169
+ className={cn(
170
+ "text-[15.5px] font-extrabold tracking-[-0.02em] sm:text-[17px]",
171
+ isHighlighted && "text-primary"
172
+ )}
173
+ >
174
+ {dayLabel}
175
+ </span>
176
+ <span
177
+ className={cn(
178
+ "font-mono text-xs",
179
+ isHighlighted ? "text-primary" : "text-muted-foreground"
180
+ )}
181
+ >
182
+ {countLabel}
183
+ </span>
184
+
185
+ {actionHref ? (
186
+ <LinkComponent href={actionHref} className="ml-auto">
187
+ {action}
188
+ </LinkComponent>
189
+ ) : onActionClick ? (
190
+ <button
191
+ type="button"
192
+ onClick={onActionClick}
193
+ className="ml-auto cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-md"
194
+ >
195
+ {action}
196
+ </button>
197
+ ) : (
198
+ action
199
+ )}
200
+ </div>
201
+
202
+ {prefersReducedMotion ? (
203
+ <div className="grid grid-cols-2 gap-3.5 px-4">{children}</div>
204
+ ) : (
205
+ <div className="overflow-hidden px-4">
206
+ <div
207
+ className="flex gap-3.5"
208
+ style={{
209
+ animationName: WEEKEND_PANEL_SLIDE_KEYFRAMES_ID,
210
+ animationDuration: `${durationSeconds}s`,
211
+ animationTimingFunction: "ease-in-out",
212
+ animationIterationCount: "infinite",
213
+ animationPlayState: isPaused ? "paused" : "running",
214
+ }}
215
+ >
216
+ {React.Children.map(children, (child) => (
217
+ <div className="w-[calc((100%-14px)/2)] flex-none">{child}</div>
218
+ ))}
219
+ </div>
220
+ </div>
221
+ )}
222
+
223
+ {!prefersReducedMotion && (
224
+ <div className="mt-3.5 grid grid-cols-[1fr_auto_1fr] items-center px-4">
225
+ <span aria-hidden />
226
+
227
+ <div className="flex items-center justify-center gap-1.5" aria-hidden>
228
+ <span
229
+ className="block h-1.5 w-[18px] rounded-full bg-primary"
230
+ style={{
231
+ animationName: WEEKEND_PANEL_DOT_A_KEYFRAMES_ID,
232
+ animationDuration: `${durationSeconds}s`,
233
+ animationTimingFunction: "ease-in-out",
234
+ animationIterationCount: "infinite",
235
+ animationPlayState: isPaused ? "paused" : "running",
236
+ }}
237
+ />
238
+ <span
239
+ className="block h-1.5 w-1.5 rounded-full bg-muted-foreground/35"
240
+ style={{
241
+ animationName: WEEKEND_PANEL_DOT_B_KEYFRAMES_ID,
242
+ animationDuration: `${durationSeconds}s`,
243
+ animationTimingFunction: "ease-in-out",
244
+ animationIterationCount: "infinite",
245
+ animationPlayState: isPaused ? "paused" : "running",
246
+ }}
247
+ />
248
+ </div>
249
+
250
+ <div className="flex justify-end">
251
+ {/* `MotionPlayPause`, not two swapped lucide glyphs. The triangle
252
+ folding into two bars IS the state change, so the control shows
253
+ what it did instead of cutting to a different icon — which is
254
+ the whole reason that gesture exists in the set.
255
+
256
+ `active` drives the HELD state and the bars are the active
257
+ variant, so it is `!isManuallyPaused`: while the carousel plays,
258
+ the button offers pause. `replayToken` bumps on pointer down so
259
+ the gesture answers the touch rather than its consequence.
260
+
261
+ `motion-reduce` is handled inside `MotionIcon`; the button keeps
262
+ its own for the background transition. */}
263
+ <button
264
+ type="button"
265
+ onClick={() => setIsManuallyPaused((current) => !current)}
266
+ onPointerDown={() => setPlayPauseReplayToken((n) => n + 1)}
267
+ aria-label={isManuallyPaused ? labels.play : labels.pause}
268
+ aria-pressed={isManuallyPaused}
269
+ className="flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors duration-150 hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 motion-reduce:transition-none"
270
+ >
271
+ <MotionPlayPause
272
+ active={!isManuallyPaused}
273
+ replayToken={playPauseReplayToken}
274
+ className="size-4"
275
+ />
276
+ </button>
277
+ </div>
278
+ </div>
279
+ )}
280
+ </div>
281
+ );
282
+ }
283
+
284
+ export type WeekendPanelSkeletonProps = {
285
+ isHighlighted?: boolean;
286
+ className?: string;
287
+ };
288
+
289
+ /** The panel at rest: the two visible cards of the two-up carousel, no slide. */
290
+ export function WeekendPanelSkeleton({ isHighlighted, className }: WeekendPanelSkeletonProps) {
291
+ return (
292
+ <div
293
+ aria-hidden="true"
294
+ className={cn(
295
+ "relative overflow-hidden rounded-2xl border bg-card py-4",
296
+ isHighlighted ? "border-primary/40" : "border-border",
297
+ className
298
+ )}
299
+ >
300
+ <div className="flex items-baseline gap-[9px] px-4 pb-3">
301
+ <Skeleton className="h-[15.5px] w-16 sm:h-[17px]" />
302
+ <Skeleton className="h-3 w-14" />
303
+ <Skeleton className="ml-auto h-3 w-14" />
304
+ </div>
305
+
306
+ <div className="grid grid-cols-2 gap-3.5 px-4">
307
+ {Array.from({ length: 2 }, (_, index) => (
308
+ <div key={index} className="flex flex-col gap-1.5">
309
+ <Skeleton className="aspect-[4/3] w-full" />
310
+ <Skeleton className="h-[13.5px] w-4/5" />
311
+ <Skeleton className="h-3 w-3/5" />
312
+ </div>
313
+ ))}
314
+ </div>
315
+
316
+ <div className="flex items-center justify-center gap-1.5 pt-3.5">
317
+ <span className="block h-1.5 w-[18px] rounded-full bg-muted" />
318
+ <span className="block h-1.5 w-1.5 rounded-full bg-muted" />
319
+ </div>
320
+ </div>
321
+ );
322
+ }
package/src/globals.css CHANGED
@@ -137,6 +137,17 @@ h6 {
137
137
  0 2px 4px -1px oklch(0.52 0 0 / 0.07), 0 10px 24px -6px oklch(0.52 0 0 / 0.16);
138
138
  --shadow-overlay:
139
139
  0 4px 12px -2px oklch(0.52 0 0 / 0.1), 0 16px 48px -12px oklch(0.52 0 0 / 0.2);
140
+ /* Panel elevation — the same apparent height as --shadow-card under a much
141
+ larger object. Elevation is a function of area: a 6px penumbra under a
142
+ 700px panel reads as a print bevel, not a lift, so the blur widens and the
143
+ peak alpha drops and the total ink scales with the object instead of
144
+ stacking. Deliberately stays BELOW --shadow-card-hover in weight — a
145
+ RESTING panel that out-shadows a card's hover makes hover stop meaning
146
+ anything anywhere. And not --shadow-overlay, which is plane 2 (sheets,
147
+ dialogs); this sits in page flow. Chroma 0, like its neighbours: a 16px
148
+ blur across a full-width panel is the largest soft area on the page and
149
+ the least attributable place for a colour cast. */
150
+ --shadow-panel: 0 1px 2px oklch(0.52 0 0 / 0.04), 0 4px 16px -4px oklch(0.52 0 0 / 0.07);
140
151
 
141
152
  /* Brown variant — replaces hardcoded #482E1F across button/badge */
142
153
  --brown: oklch(0.31 0.05 45);
@@ -205,6 +216,13 @@ h6 {
205
216
  --ease-spring-smooth: cubic-bezier(0.22, 1, 0.36, 1);
206
217
  --ease-spring-soft: cubic-bezier(0.16, 1, 0.3, 1);
207
218
 
219
+ /* Timeline geometry — shared by `TimelineBand`, which lays the columns out,
220
+ and `HourGroup`, whose spine puck positions itself against that gutter from
221
+ inside the content column. Two components deriving the same offset from two
222
+ hardcoded numbers is how they drift apart. */
223
+ --timeline-lane: 396px;
224
+ --timeline-spine: 6rem;
225
+
208
226
  /* Z-index scale */
209
227
  --z-base: 0;
210
228
  --z-sticky: 100;
@@ -265,6 +283,7 @@ h6 {
265
283
  --shadow-card: 0 1px 3px oklch(0 0 0 / 0.2), 0 1px 2px oklch(0 0 0 / 0.15);
266
284
  --shadow-card-hover: 0 4px 16px oklch(0 0 0 / 0.3), 0 1px 4px oklch(0 0 0 / 0.2);
267
285
  --shadow-overlay: 0 8px 32px oklch(0 0 0 / 0.4), 0 2px 8px oklch(0 0 0 / 0.25);
286
+ --shadow-panel: 0 1px 3px oklch(0 0 0 / 0.24), 0 8px 24px -8px oklch(0 0 0 / 0.34);
268
287
  --sidebar: oklch(0.205 0 0);
269
288
  --sidebar-foreground: oklch(0.985 0 0);
270
289
  --sidebar-primary: oklch(0.488 0.243 264.376);
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The purpose → glyph registry.
3
+ *
4
+ * One place recording which glyph means what. Before this existed, ~10 ad-hoc
5
+ * glyph maps were scattered across the app and the same purpose ended up drawn
6
+ * two different ways: back as both an arrow and a chevron, "open filters" as
7
+ * both a funnel and sliders (one surface used both, chosen by which view you
8
+ * were in), clear and close sharing a bare X.
9
+ *
10
+ * Only GENERIC action vocabulary lives here. Product taxonomy — cuisine
11
+ * shortcuts, facet dimensions, digest categories — stays in the consuming app.
12
+ *
13
+ * ## The rules this encodes
14
+ *
15
+ * 1. **Arrow leaves, chevron stays.** `ArrowRight` means "you are going to a new
16
+ * destination" (see-all links, banner CTAs). `ChevronRight` means "reveal
17
+ * more, in place" and is scoped to exactly two jobs: carousel paging and
18
+ * row-contained drill-in. Collapsing these is what produced a glyph with
19
+ * four meanings.
20
+ * 2. **Sliders, not a funnel.** A funnel reads as "narrow a list" and collides
21
+ * with search and sort iconography at 24px; three sliders read
22
+ * unambiguously as "adjust parameters" and carry a badge count cleanly.
23
+ * 3. **Containment separates clear from close.** A bare `X` leaves a layer. A
24
+ * contained `XCircle` resets a value in place without leaving. In a dense
25
+ * filter row, bare Xs beside each other cannot say which layer they close.
26
+ * 4. **Tappable has a container or a colour state; information is muted and
27
+ * bare.** This is what disambiguates the overloaded glyphs (`MapPin`,
28
+ * `Star`) rather than picking different glyphs for each sense. An
29
+ * interactive instance sits in a chip or button and takes an active colour;
30
+ * a decorative one is `text-muted-foreground`, `aria-hidden`, outside any
31
+ * button, with no hover or active transition.
32
+ * 5. **Loved and want-to-try are two actions, not one.** `Heart` is
33
+ * retrospective ("I have had this and I love it"), `Bookmark` is prospective
34
+ * ("I want to try this"). They feed different lists and are mutually
35
+ * exclusive per item.
36
+ */
37
+
38
+ import {
39
+ ArrowLeft,
40
+ ArrowRight,
41
+ ArrowUpDown,
42
+ Bookmark,
43
+ Check,
44
+ ChevronDown,
45
+ ChevronLeft,
46
+ ChevronRight,
47
+ ExternalLink,
48
+ Flag,
49
+ Heart,
50
+ Info,
51
+ Navigation,
52
+ Phone,
53
+ Plus,
54
+ RefreshCw,
55
+ Search,
56
+ Share2,
57
+ SlidersHorizontal,
58
+ Trash2,
59
+ X,
60
+ XCircle,
61
+ type LucideIcon,
62
+ } from "lucide-react";
63
+
64
+ export type IconPurpose =
65
+ // navigation
66
+ | "back"
67
+ | "seeAll"
68
+ | "drillIn"
69
+ | "carouselPrev"
70
+ | "carouselNext"
71
+ | "directions"
72
+ | "externalLink"
73
+ // layers and values
74
+ | "close"
75
+ | "clear"
76
+ | "expand"
77
+ // list controls
78
+ | "filter"
79
+ | "sort"
80
+ | "search"
81
+ // actions
82
+ | "share"
83
+ | "favorite"
84
+ | "wantToTry"
85
+ | "confirm"
86
+ | "add"
87
+ | "remove"
88
+ | "refresh"
89
+ | "call"
90
+ | "report"
91
+ | "info";
92
+
93
+ export const iconFor: Record<IconPurpose, LucideIcon> = {
94
+ back: ArrowLeft,
95
+ seeAll: ArrowRight,
96
+ drillIn: ChevronRight,
97
+ carouselPrev: ChevronLeft,
98
+ carouselNext: ChevronRight,
99
+ directions: Navigation,
100
+ externalLink: ExternalLink,
101
+
102
+ close: X,
103
+ clear: XCircle,
104
+ expand: ChevronDown,
105
+
106
+ filter: SlidersHorizontal,
107
+ sort: ArrowUpDown,
108
+ search: Search,
109
+
110
+ share: Share2,
111
+ favorite: Heart,
112
+ wantToTry: Bookmark,
113
+ confirm: Check,
114
+ add: Plus,
115
+ remove: Trash2,
116
+ refresh: RefreshCw,
117
+ call: Phone,
118
+ report: Flag,
119
+ info: Info,
120
+ };
121
+
122
+ /**
123
+ * Purposes whose glyph must rotate to show state, and by how much.
124
+ * `expand` is the disclosure chevron: pointing down at rest, up when open.
125
+ */
126
+ export const iconRotation: Partial<Record<IconPurpose, number>> = {
127
+ expand: 180,
128
+ };
129
+
130
+ /**
131
+ * Retired glyphs and what replaced them. Kept so a reviewer can tell a
132
+ * deliberate choice from a regression, and so lint/codemods have a source.
133
+ */
134
+ export const retiredIcons = {
135
+ Filter: "filter → SlidersHorizontal",
136
+ ChevronRightAsSeeAll: "seeAll → ArrowRight",
137
+ ChevronLeftAsBack: "back → ArrowLeft",
138
+ XAsClear: "clear → XCircle",
139
+ } as const;
package/src/test/setup.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  import "@testing-library/jest-dom";
2
2
 
3
- global.ResizeObserver = class ResizeObserver {
3
+ // `globalThis`, not `global`: the latter is a Node-only global, and this
4
+ // project's `lib` is dom / dom.iterable / esnext with no `@types/node`, so it
5
+ // did not typecheck. This file already reaches for `window` below — the test
6
+ // environment is jsdom, and `globalThis` is the spelling that is typed in both.
7
+ globalThis.ResizeObserver = class ResizeObserver {
4
8
  observe() {}
5
9
  unobserve() {}
6
10
  disconnect() {}