@bearmenu/ui 0.8.17 → 0.8.18

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 (71) hide show
  1. package/dist/{chunk-WYTJR2YU.js → chunk-5CPBSNWQ.js} +6 -1
  2. package/dist/{chunk-54FQQZZJ.js → chunk-EQYB4Q77.js} +1 -1
  3. package/dist/chunk-LYREUZH6.js +156 -0
  4. package/dist/chunk-PW247TG2.js +81 -0
  5. package/dist/components/alert.d.ts +2 -2
  6. package/dist/components/badge.d.ts +1 -1
  7. package/dist/components/button.d.ts +1 -1
  8. package/dist/components/card.d.ts +2 -2
  9. package/dist/components/command.d.ts +15 -15
  10. package/dist/components/concept-compare-rail.js +2 -2
  11. package/dist/components/concept-compare.js +2 -2
  12. package/dist/components/event-card.d.ts +22 -4
  13. package/dist/components/event-card.js +95 -50
  14. package/dist/components/event-day-header.d.ts +5 -3
  15. package/dist/components/event-day-header.js +12 -4
  16. package/dist/components/event-door.d.ts +41 -0
  17. package/dist/components/event-door.js +65 -0
  18. package/dist/components/event-hub-hero.d.ts +10 -2
  19. package/dist/components/event-hub-hero.js +11 -5
  20. package/dist/components/event-tonight-strip.d.ts +50 -0
  21. package/dist/components/event-tonight-strip.js +67 -0
  22. package/dist/components/item.d.ts +2 -2
  23. package/dist/components/menu-hub-hero.d.ts +7 -1
  24. package/dist/components/menu-hub-hero.js +2 -1
  25. package/dist/components/place-card.js +1 -1
  26. package/dist/components/poster-stack.d.ts +36 -3
  27. package/dist/components/poster-stack.js +49 -18
  28. package/dist/components/snap-rail.d.ts +2 -2
  29. package/dist/components/snap-rail.js +1 -1
  30. package/dist/components/spinner.d.ts +2 -2
  31. package/dist/components/tag.d.ts +2 -2
  32. package/dist/components/text.d.ts +3 -3
  33. package/dist/components/timeline-day.d.ts +54 -0
  34. package/dist/components/timeline-day.js +65 -0
  35. package/dist/components/timeline-hour-puck.d.ts +47 -0
  36. package/dist/components/timeline-hour-puck.js +3 -0
  37. package/dist/components/timeline-hour.d.ts +96 -0
  38. package/dist/components/{timeline-band.js → timeline-hour.js} +2 -1
  39. package/dist/components/timeline-playhead.js +1 -3
  40. package/package.json +1 -1
  41. package/src/components/event-card.tsx +70 -13
  42. package/src/components/event-day-header.tsx +19 -7
  43. package/src/components/event-door.stories.tsx +88 -0
  44. package/src/components/event-door.tsx +97 -0
  45. package/src/components/event-hub-hero.tsx +21 -3
  46. package/src/components/event-tonight-strip.stories.tsx +78 -0
  47. package/src/components/event-tonight-strip.test.tsx +59 -0
  48. package/src/components/event-tonight-strip.tsx +134 -0
  49. package/src/components/menu-hub-hero.test.tsx +13 -0
  50. package/src/components/menu-hub-hero.tsx +9 -2
  51. package/src/components/poster-stack.stories.tsx +43 -1
  52. package/src/components/poster-stack.tsx +98 -19
  53. package/src/components/snap-rail.tsx +6 -1
  54. package/src/components/timeline-day.stories.tsx +201 -0
  55. package/src/components/timeline-day.test.tsx +78 -0
  56. package/src/components/timeline-day.tsx +114 -0
  57. package/src/components/timeline-hour-puck.tsx +139 -0
  58. package/src/components/timeline-hour.tsx +266 -0
  59. package/src/components/timeline-playhead.tsx +1 -1
  60. package/dist/chunk-NSNEANOQ.js +0 -104
  61. package/dist/components/hour-band.d.ts +0 -66
  62. package/dist/components/hour-band.js +0 -81
  63. package/dist/components/hour-group.d.ts +0 -67
  64. package/dist/components/hour-group.js +0 -59
  65. package/dist/components/timeline-band.d.ts +0 -64
  66. package/src/components/hour-band.stories.tsx +0 -182
  67. package/src/components/hour-band.tsx +0 -146
  68. package/src/components/hour-group.stories.tsx +0 -100
  69. package/src/components/hour-group.tsx +0 -124
  70. package/src/components/timeline-band.stories.tsx +0 -206
  71. package/src/components/timeline-band.tsx +0 -247
@@ -0,0 +1,134 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { ArrowRight, CalendarDays, Clock } from "lucide-react";
5
+ import { cn } from "../lib/utils";
6
+ import { Skeleton } from "./skeleton";
7
+
8
+ /**
9
+ * EventTonightStrip — the hub's state line: what is on right now, what is
10
+ * later tonight, and one tap into the timeline.
11
+ *
12
+ * The hero's figures say SCALE ("52 events · 14 days"); this says STATE ("3
13
+ * live now · 12 more tonight · next at 20:00"). It is the one thing on the
14
+ * hub's first screen a reader can tap into today's programme, so it is a real
15
+ * anchor whenever there is a day to open — never a sheet trigger.
16
+ *
17
+ * Copy is caller-composed and carries only absolute facts: counts and
18
+ * wall-clock start times. Never a countdown or "in 12 minutes" — the hub is
19
+ * ISR-cached, and a ten-minute-old snapshot that says "next at 20:00" is at
20
+ * worst one boundary off, while "in 12 minutes" is simply wrong. The DS keeps
21
+ * no clock, no Intl and no i18n: the app derives `tone`, `lead` and `details`
22
+ * from its own clock and re-derives them after hydration.
23
+ *
24
+ * The lead stays `text-foreground`, not `--success`: 13px bold success on a
25
+ * card computes to roughly 4.5:1 in both modes, too close to the floor for
26
+ * the strip's one claim. The dot and the border carry the colour.
27
+ */
28
+
29
+ export type EventTonightStripTone = "live" | "tonight" | "quiet" | "none";
30
+
31
+ export type EventTonightStripLinkProps = {
32
+ href: string;
33
+ className?: string;
34
+ children: React.ReactNode;
35
+ onClick?: (mouseEvent: React.MouseEvent) => void;
36
+ };
37
+
38
+ function DefaultLink({ href, ...props }: EventTonightStripLinkProps) {
39
+ return <a href={href} {...props} />;
40
+ }
41
+
42
+ export type EventTonightStripProps = {
43
+ tone: EventTonightStripTone;
44
+ /** The lead segment, bold — "3 live now". */
45
+ lead: string;
46
+ /** Trailing segments, muted, joined with " · " — ["12 more tonight", "next at 20:00"]. */
47
+ details?: string[];
48
+ /** Screen-reader suffix for the anchor — "Open tonight in the schedule". */
49
+ actionLabel?: string;
50
+ /** Absent (tone `none`) renders a non-interactive `role="status"` line. */
51
+ href?: string;
52
+ /** Defaults to a plain `<a>`. Pass `next/link` in a Next app. */
53
+ linkComponent?: React.ComponentType<EventTonightStripLinkProps>;
54
+ className?: string;
55
+ };
56
+
57
+ export function EventTonightStrip({
58
+ tone,
59
+ lead,
60
+ details = [],
61
+ actionLabel,
62
+ href,
63
+ linkComponent: LinkComponent = DefaultLink,
64
+ className,
65
+ }: EventTonightStripProps) {
66
+ const interactive = !!href;
67
+
68
+ const mark =
69
+ tone === "live" ? (
70
+ <span className="relative flex h-2.5 w-2.5" aria-hidden>
71
+ <span className="absolute inline-flex h-full w-full rounded-full bg-success/40 motion-live-pulse motion-reduce:animate-none" />
72
+ <span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-success" />
73
+ </span>
74
+ ) : tone === "tonight" ? (
75
+ <Clock className="h-4 w-4 text-muted-foreground" aria-hidden />
76
+ ) : (
77
+ <CalendarDays
78
+ className={cn("h-4 w-4", tone === "none" ? "text-muted-foreground/70" : "text-muted-foreground")}
79
+ aria-hidden
80
+ />
81
+ );
82
+
83
+ const body = (
84
+ <>
85
+ <span className="flex h-4 w-4 shrink-0 items-center justify-center">{mark}</span>
86
+ <span className="line-clamp-2 min-w-0 flex-1 text-[13px] leading-[1.3] tabular-nums lg:line-clamp-1 lg:text-sm">
87
+ <span className="font-bold text-foreground">{lead}</span>
88
+ {details.map((detail) => (
89
+ <React.Fragment key={detail}>
90
+ {/* The glyph is decoration; the spaces around it are real, so the
91
+ accessible name reads as words, not one run-on string. */}{" "}
92
+ <span className="text-muted-foreground" aria-hidden>
93
+ ·
94
+ </span>{" "}
95
+ <span className="text-muted-foreground">{detail}</span>
96
+ </React.Fragment>
97
+ ))}
98
+ {interactive && actionLabel && <span className="sr-only">. {actionLabel}</span>}
99
+ </span>
100
+ {interactive && (
101
+ <ArrowRight className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
102
+ )}
103
+ </>
104
+ );
105
+
106
+ const rootClasses = cn(
107
+ "flex min-h-[52px] w-full items-center gap-2.5 rounded-[14px] border bg-card px-3.5 py-2 text-left",
108
+ tone === "live" ? "border-success/40" : "border-border",
109
+ interactive &&
110
+ cn(
111
+ "cursor-pointer transition-colors duration-150 hover:bg-accent-subtle active:scale-[0.99] motion-reduce:transition-none",
112
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
113
+ ),
114
+ className
115
+ );
116
+
117
+ if (href) {
118
+ return (
119
+ <LinkComponent href={href} className={rootClasses}>
120
+ {body}
121
+ </LinkComponent>
122
+ );
123
+ }
124
+
125
+ return (
126
+ <div role="status" className={rootClasses}>
127
+ {body}
128
+ </div>
129
+ );
130
+ }
131
+
132
+ export function EventTonightStripSkeleton({ className }: { className?: string }) {
133
+ return <Skeleton className={cn("h-[52px] w-full rounded-[14px]", className)} />;
134
+ }
@@ -28,6 +28,19 @@ describe("MenuHubHero mosaic", () => {
28
28
  });
29
29
  });
30
30
 
31
+ describe("MenuHubHero heading level", () => {
32
+ it("renders the headline as h1 by default", () => {
33
+ render(<MenuHubHero headline="Every kitchen" />);
34
+ expect(document.querySelector("h1")?.textContent).toBe("Every kitchen");
35
+ });
36
+
37
+ it("renders as h2 for a page that already owns its h1", () => {
38
+ render(<MenuHubHero headline="Every kitchen" headingLevel="h2" />);
39
+ expect(document.querySelector("h1")).toBeNull();
40
+ expect(document.querySelector("h2")?.textContent).toBe("Every kitchen");
41
+ });
42
+ });
43
+
31
44
  describe("VenueWall mosaic", () => {
32
45
  it("follows the hero's rule: six on the phone, eighteen from lg", () => {
33
46
  render(<VenueWall places={[]} headline="Every menu" tileUrls={tiles} />);
@@ -60,6 +60,12 @@ export type MenuHubHeroProps = {
60
60
  * a 1440px hero.
61
61
  */
62
62
  tileUrls?: string[];
63
+ /**
64
+ * The element the headline renders as. `h1` by default; a page that
65
+ * already owns its `<h1>` (an sr-only SEO heading, as on `/menu`) passes
66
+ * `h2` so the document has one h1, not two.
67
+ */
68
+ headingLevel?: "h1" | "h2";
63
69
  className?: string;
64
70
  };
65
71
 
@@ -84,6 +90,7 @@ export function MenuHubHero({
84
90
  action,
85
91
  backgroundUrl,
86
92
  tileUrls,
93
+ headingLevel: Heading = "h1",
87
94
  className,
88
95
  }: MenuHubHeroProps) {
89
96
  return (
@@ -160,9 +167,9 @@ export function MenuHubHero({
160
167
 
161
168
  {/* 28px on a phone: 32px on a 358px column is five or six words a
162
169
  line, which left "walk in blind." wrapping alone. */}
163
- <h1 className="max-w-[640px] font-serif text-[28px] font-semibold leading-[1.06] tracking-[-0.025em] lg:text-[50px] lg:leading-[1.04]">
170
+ <Heading className="max-w-[640px] font-serif text-[28px] font-semibold leading-[1.06] tracking-[-0.025em] lg:text-[50px] lg:leading-[1.04]">
164
171
  {headline}
165
- </h1>
172
+ </Heading>
166
173
 
167
174
  {body && (
168
175
  <p className="hidden max-w-[540px] text-sm leading-[1.6] opacity-[0.84] lg:block lg:text-[15.5px]">
@@ -56,7 +56,7 @@ export const TwoPosters: Story = {
56
56
  },
57
57
  };
58
58
 
59
- /** No `src` renders the neutral placeholder block, same as `EventCard`. */
59
+ /** No `src` renders the poster fallback, same as `EventCard`. */
60
60
  export const MissingSources: Story = {
61
61
  args: {
62
62
  posters: [
@@ -66,3 +66,45 @@ export const MissingSources: Story = {
66
66
  ],
67
67
  },
68
68
  };
69
+
70
+ /**
71
+ * Tappable: every poster an anchor to its event, the title captioned at its
72
+ * foot. Hover or focus a poster and it comes to the front; the press scale
73
+ * is on the anchor, the float on the wrapper, so the two never fight.
74
+ */
75
+ export const Tappable: Story = {
76
+ args: {
77
+ posters: [
78
+ {
79
+ src: posterDataUri("1", "#7a5c46"),
80
+ alt: "Psihotrop poster",
81
+ caption: "Psihotrop — live at Form Space",
82
+ href: "#psihotrop",
83
+ },
84
+ {
85
+ src: posterDataUri("2", "#4a6a5c"),
86
+ alt: "Warehouse rave poster",
87
+ caption: "Warehouse rave: all night long",
88
+ href: "#warehouse",
89
+ },
90
+ {
91
+ src: posterDataUri("3", "#6a5c8a"),
92
+ alt: "Jazz night poster",
93
+ caption: "Jazz night at the Jazz Club",
94
+ href: "#jazz",
95
+ },
96
+ ],
97
+ },
98
+ };
99
+
100
+ /** The phone geometry: three posters inside a 343px column, none past the gutter. */
101
+ export const Phone: Story = {
102
+ ...Tappable,
103
+ decorators: [
104
+ (Story) => (
105
+ <div className="w-[343px] border-x border-dashed border-border">
106
+ <Story />
107
+ </div>
108
+ ),
109
+ ],
110
+ };
@@ -3,11 +3,29 @@
3
3
  import * as React from "react";
4
4
  import { useEffect, useRef, useState } from "react";
5
5
  import { cn } from "../lib/utils";
6
+ import { EventPosterFallback } from "./event-poster-fallback";
6
7
 
7
8
  /**
8
9
  * PosterStack — three overlapping, gently rotated posters that float, the
9
10
  * hub hero's aside slot in the source design.
10
11
  *
12
+ * TAPPABLE SINCE 0.8.18. The stack was three decorative images: the loudest
13
+ * thing on the hub's first screen, and the one thing on it a reader could not
14
+ * tap. A poster with `href` is an anchor now — the event's detail page for
15
+ * the crawler and a modified click, whatever the app's `linkComponent` does
16
+ * for a plain tap (the hub opens its preview drawer). `caption` prints the
17
+ * title at the poster's foot: it gives the tap a visible reason on touch,
18
+ * where there is no hover, and it is the anchor's accessible name, so the
19
+ * `<img>` goes `alt=""` whenever a caption is set. Without `href` a poster is
20
+ * decorative, as before.
21
+ *
22
+ * The float and the press live on DIFFERENT elements. The wrapper carries
23
+ * the keyframe `transform` (rotation + drift); the anchor inside it carries
24
+ * the press scale and the focus ring. One element cannot animate one
25
+ * `transform` and transition another. The pointed poster comes to the front
26
+ * (`hover:z-[4]`, `focus-within:z-[4]`) — z-index is not animated, it is the
27
+ * "pick this one" cue.
28
+ *
11
29
  * Two motion rules, both mandatory:
12
30
  * 1. Stops entirely under `prefers-reduced-motion: reduce` — enforced via
13
31
  * an injected `<style>` media query rather than relying on Tailwind's
@@ -20,15 +38,35 @@ import { cn } from "../lib/utils";
20
38
  * reduced motion is on, since nothing is animating to pause.
21
39
  */
22
40
 
41
+ export type PosterStackLinkProps = {
42
+ href: string;
43
+ className?: string;
44
+ children: React.ReactNode;
45
+ onClick?: (mouseEvent: React.MouseEvent) => void;
46
+ };
47
+
48
+ function DefaultLink({ href, ...props }: PosterStackLinkProps) {
49
+ return <a href={href} {...props} />;
50
+ }
51
+
23
52
  export type PosterStackPoster = {
24
- /** Absent renders a neutral placeholder block instead of an image. */
53
+ /** Absent renders `fallback` instead of an image. */
25
54
  src?: string;
26
55
  alt: string;
56
+ /** The event's detail page. With it the poster is an anchor; without it, decorative. */
57
+ href?: string;
58
+ /** Caption at the poster's foot — the event title. Becomes the anchor's
59
+ * accessible name; the `<img>` then gets `alt=""`. */
60
+ caption?: string;
61
+ /** Rendered when `src` is absent or fails. Defaults to `<EventPosterFallback />`. */
62
+ fallback?: React.ReactNode;
27
63
  };
28
64
 
29
65
  export type PosterStackProps = {
30
66
  /** Rendered in order; only the first three are shown. */
31
67
  posters: PosterStackPoster[];
68
+ /** Defaults to a plain `<a>`. Pass `next/link`, or an anchor that opens the app's preview. */
69
+ linkComponent?: React.ComponentType<PosterStackLinkProps>;
32
70
  className?: string;
33
71
  };
34
72
 
@@ -51,22 +89,28 @@ type PosterSlotConfig = {
51
89
  zIndexClassName?: string;
52
90
  };
53
91
 
92
+ /**
93
+ * Phone geometry fits a 343px column (375 minus the 16px gutters): the third
94
+ * poster used to reach 358px and ran into the right gutter. 112 / 128 / 112
95
+ * wide, 212px tall — 38px shorter than before, which is what lets the door
96
+ * row land in the first screen. Touch targets are 112×149 and 128×171.
97
+ */
54
98
  const POSTER_SLOTS: PosterSlotConfig[] = [
55
99
  {
56
- positionClassName: "left-[6px] top-[34px] w-[120px] md:left-0 md:top-[52px] md:w-[196px]",
100
+ positionClassName: "left-0 top-[28px] w-[112px] md:left-0 md:top-[52px] md:w-[196px]",
57
101
  restTransform: "rotate(-5deg)",
58
102
  animationName: "ds-poster-stack-float-1",
59
103
  animationDurationMs: 9000,
60
104
  },
61
105
  {
62
- positionClassName: "left-[110px] top-0 w-[140px] md:left-[168px] md:w-[232px]",
106
+ positionClassName: "left-[104px] top-0 w-[128px] md:left-[168px] md:w-[232px]",
63
107
  restTransform: "rotate(3deg)",
64
108
  animationName: "ds-poster-stack-float-2",
65
109
  animationDurationMs: 11000,
66
110
  zIndexClassName: "z-[3]",
67
111
  },
68
112
  {
69
- positionClassName: "left-[238px] top-[58px] w-[120px] md:left-[378px] md:top-24 md:w-[196px]",
113
+ positionClassName: "left-[224px] top-[44px] w-[112px] md:left-[378px] md:top-24 md:w-[196px]",
70
114
  restTransform: "rotate(6deg)",
71
115
  animationName: "ds-poster-stack-float-3",
72
116
  animationDurationMs: 10000,
@@ -77,7 +121,11 @@ const POSTER_SLOTS: PosterSlotConfig[] = [
77
121
  * shadow is an art-directed literal rather than a `--shadow-card` token. */
78
122
  const POSTER_SHADOW = "0 22px 50px oklch(0.3 0.02 60 / 0.24)";
79
123
 
80
- export function PosterStack({ posters, className }: PosterStackProps) {
124
+ export function PosterStack({
125
+ posters,
126
+ linkComponent: LinkComponent = DefaultLink,
127
+ className,
128
+ }: PosterStackProps) {
81
129
  const containerRef = useRef<HTMLDivElement>(null);
82
130
  const posterRefs = useRef<Array<HTMLDivElement | null>>([]);
83
131
  const [failedIndices, setFailedIndices] = useState<Set<number>>(new Set());
@@ -106,12 +154,41 @@ export function PosterStack({ posters, className }: PosterStackProps) {
106
154
  const visiblePosters = posters.slice(0, POSTER_SLOTS.length);
107
155
 
108
156
  return (
109
- <div ref={containerRef} className={cn("relative h-[250px] md:h-[400px]", className)}>
157
+ <div ref={containerRef} className={cn("relative h-[212px] md:h-[400px]", className)}>
110
158
  <style>{POSTER_STACK_ANIMATION_STYLE}</style>
111
159
 
112
160
  {visiblePosters.map((poster, index) => {
113
161
  const slot = POSTER_SLOTS[index];
114
162
  const showPoster = !!poster.src && !failedIndices.has(index);
163
+ const face = (
164
+ <>
165
+ {showPoster ? (
166
+ // eslint-disable-next-line @next/next/no-img-element
167
+ <img
168
+ src={poster.src}
169
+ alt={poster.caption ? "" : poster.alt}
170
+ loading="lazy"
171
+ decoding="async"
172
+ className={cn(
173
+ "h-full w-full object-cover",
174
+ poster.href &&
175
+ "transition-transform duration-300 group-hover/poster:scale-[1.03] motion-reduce:transition-none"
176
+ )}
177
+ onError={() => setFailedIndices((current) => new Set(current).add(index))}
178
+ />
179
+ ) : (
180
+ (poster.fallback ?? <EventPosterFallback />)
181
+ )}
182
+ {poster.caption && (
183
+ <span className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent px-2 pb-2 pt-6">
184
+ <span className="line-clamp-2 text-[11px] font-semibold leading-[1.25] text-white md:text-[12.5px]">
185
+ {poster.caption}
186
+ </span>
187
+ </span>
188
+ )}
189
+ </>
190
+ );
191
+
115
192
  return (
116
193
  <div
117
194
  key={poster.alt || index}
@@ -122,7 +199,8 @@ export function PosterStack({ posters, className }: PosterStackProps) {
122
199
  className={cn(
123
200
  "absolute aspect-[3/4] overflow-hidden rounded-xl bg-muted",
124
201
  slot.positionClassName,
125
- slot.zIndexClassName
202
+ slot.zIndexClassName,
203
+ poster.href && "hover:z-[4] focus-within:z-[4]"
126
204
  )}
127
205
  style={{
128
206
  transform: slot.restTransform,
@@ -134,18 +212,19 @@ export function PosterStack({ posters, className }: PosterStackProps) {
134
212
  animationDirection: "alternate",
135
213
  }}
136
214
  >
137
- {showPoster && (
138
- // eslint-disable-next-line @next/next/no-img-element
139
- <img
140
- src={poster.src}
141
- alt={poster.alt}
142
- loading="lazy"
143
- decoding="async"
144
- className="h-full w-full object-cover"
145
- onError={() =>
146
- setFailedIndices((current) => new Set(current).add(index))
147
- }
148
- />
215
+ {poster.href ? (
216
+ <LinkComponent
217
+ href={poster.href}
218
+ className={cn(
219
+ "group/poster relative block h-full w-full overflow-hidden rounded-xl",
220
+ "transition-transform duration-150 active:scale-[0.98] motion-reduce:transition-none",
221
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
222
+ )}
223
+ >
224
+ {face}
225
+ </LinkComponent>
226
+ ) : (
227
+ <div className="relative h-full w-full">{face}</div>
149
228
  )}
150
229
  </div>
151
230
  );
@@ -29,6 +29,9 @@ const snapRailVariants = cva(
29
29
  {
30
30
  variants: {
31
31
  itemWidth: {
32
+ /** Square door tiles — three and a third per phone screen, so the
33
+ * cut fourth says "swipe"; more as the viewport widens. */
34
+ xs: "[&>*]:w-[30%] sm:[&>*]:w-[22%] md:[&>*]:w-[18%]",
32
35
  sm: "[&>*]:w-[46%]",
33
36
  md: "[&>*]:w-[60%]",
34
37
  lg: "[&>*]:w-[80%]",
@@ -42,7 +45,9 @@ const snapRailVariants = cva(
42
45
  bleed: {
43
46
  none: "",
44
47
  card: "-mx-3.5 px-3.5 scroll-px-3.5",
45
- page: "-mx-4 px-4 scroll-px-4",
48
+ /** The consumer's page gutter, read from `--page-gutter` (16px on a
49
+ * phone, 24px from md) — a fixed `-mx-4` was 8px short at md. */
50
+ page: "-mx-[var(--page-gutter,1rem)] px-[var(--page-gutter,1rem)] scroll-px-[var(--page-gutter,1rem)]",
46
51
  },
47
52
  },
48
53
  defaultVariants: {
@@ -0,0 +1,201 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { TimelineDay, TimelineDaySkeleton } from "./timeline-day";
3
+ import { TimelineHour } from "./timeline-hour";
4
+ import { EventCard } from "./event-card";
5
+ import { EventDayHeader } from "./event-day-header";
6
+ import type { EventCardLabels, EventFormatters, EventPreview } from "./event-types";
7
+
8
+ const labels: EventCardLabels = { free: "Free", liveNow: "Live now" };
9
+
10
+ const formatters: EventFormatters = {
11
+ time: (iso) => iso.slice(11, 16),
12
+ day: () => "Thu, Sep 3",
13
+ price: (amount) => `${amount} RON`,
14
+ };
15
+
16
+ const POSTER =
17
+ "https://images.unsplash.com/photo-1514525253161-7a46d19cd819?w=640&q=70&auto=format&fit=crop";
18
+
19
+ const event = (
20
+ id: string,
21
+ title: string,
22
+ place: string,
23
+ hour: string,
24
+ fee?: number,
25
+ poster = POSTER
26
+ ): EventPreview => ({
27
+ id,
28
+ title,
29
+ location: place,
30
+ place_name: place,
31
+ starts_at: `2026-09-03T${hour}:00`,
32
+ entry_fee: fee,
33
+ poster_url: poster,
34
+ });
35
+
36
+ const meta: Meta<typeof TimelineDay> = {
37
+ title: "Events/TimelineDay",
38
+ component: TimelineDay,
39
+ tags: ["autodocs"],
40
+ decorators: [
41
+ (Story) => (
42
+ <div className="mx-auto max-w-[1180px] bg-background px-4">
43
+ <Story />
44
+ </div>
45
+ ),
46
+ ],
47
+ parameters: {
48
+ layout: "fullscreen",
49
+ docs: {
50
+ description: {
51
+ component:
52
+ "One day of the timeline, ONE DOM for both viewports. Below `lg` it is a column of hours, each a `TimelineHour` with a gutter chip and the hour's cards. From `lg` the same nodes become lane │ spine │ content: the hour with `role=\"hero\"` is placed in the lane by the grid and pins, every other hour stations itself on the spine, and cards past the cap paint as rows (`EventCard density=\"row-lg\"`). Resize the canvas across 1024px to watch one tree change shape. `rows` counts the column-3 children.",
53
+ },
54
+ },
55
+ },
56
+ };
57
+
58
+ export default meta;
59
+ type Story = StoryObj<typeof TimelineDay>;
60
+
61
+ function Card(props: {
62
+ ev: EventPreview;
63
+ emphasis?: boolean | "lg";
64
+ hideWhen?: boolean | "below-lg";
65
+ density?: "card" | "row-lg";
66
+ liveStatus?: string;
67
+ priority?: boolean;
68
+ }) {
69
+ return (
70
+ <EventCard
71
+ event={props.ev}
72
+ labels={labels}
73
+ formatters={formatters}
74
+ emphasis={props.emphasis}
75
+ hideWhen={props.hideWhen ?? true}
76
+ density={props.density}
77
+ liveStatus={props.liveStatus}
78
+ showPrice
79
+ tags={["Live music", "Jazz"]}
80
+ href={`#${props.ev.id}`}
81
+ priority={props.priority}
82
+ />
83
+ );
84
+ }
85
+
86
+ /**
87
+ * A six-hour Friday with the hero in the middle: 19:00 opens, the 20:00 hero
88
+ * is lifted into the lane on desktop while its hour's other event stays as a
89
+ * continuation, later hours demote to rows past the cap, and a group of
90
+ * events the ingest never timed trails the day off the axis.
91
+ */
92
+ export const FullDay: Story = {
93
+ render: () => (
94
+ <div className="flex flex-col gap-3 py-6">
95
+ <EventDayHeader
96
+ dayNumber="03"
97
+ weekday="Thursday"
98
+ meta="September · 9 events · 1 live"
99
+ size="responsive"
100
+ />
101
+ <TimelineDay rows={6} laneTop="16px">
102
+ <TimelineHour time="19:00" countLabel="2 events">
103
+ <Card ev={event("a", "Vinyl night at the bar", "Joben Bistro", "19:00", 0)} priority />
104
+ <Card ev={event("b", "Pub quiz: nineties", "Klausen", "19:00", 20)} />
105
+ </TimelineHour>
106
+ <TimelineHour time="20:00" role="hero" isLive liveLabel="NOW ON" countLabel="1 event">
107
+ <Card
108
+ ev={event("c", "Subcarpați live — sold-out tour warm-up", "Form Space", "20:00", 60)}
109
+ emphasis="lg"
110
+ hideWhen="below-lg"
111
+ liveStatus="Live now · ends around 23:00"
112
+ />
113
+ </TimelineHour>
114
+ <TimelineHour time="20:00" role="continuation" isLive liveLabel="NOW ON" countLabel="1 event">
115
+ <Card ev={event("d", "Wine tasting: Dealu Mare", "Enoteca", "20:00", 90)} />
116
+ </TimelineHour>
117
+ <TimelineHour time="21:00" countLabel="3 events" layout="rows">
118
+ <Card ev={event("e", "Comedy open mic", "Flying Circus", "21:00", 25)} density="row-lg" />
119
+ <Card ev={event("f", "Techno: Berlin calling", "Mansion", "21:00", 40)} density="row-lg" />
120
+ <Card ev={event("g", "Salsa social", "Casa Tranzit", "21:00", 0)} density="row-lg" />
121
+ </TimelineHour>
122
+ <TimelineHour time="23:00" countLabel="1 event" layout="rows">
123
+ <Card ev={event("h", "Late-night jazz jam", "Jazz Club", "23:00")} density="row-lg" />
124
+ </TimelineHour>
125
+ <TimelineHour
126
+ time="Time to be confirmed"
127
+ role="unscheduled"
128
+ countLabel="1 event"
129
+ note="The venue has not published a start time."
130
+ >
131
+ <Card ev={event("i", "Board games afternoon", "Insomnia Café", "00:00", 0)} />
132
+ </TimelineHour>
133
+ </TimelineDay>
134
+ </div>
135
+ ),
136
+ };
137
+
138
+ /** A quiet Tuesday: one hour, one event, no hero — no lane on desktop either. */
139
+ export const OneHour: Story = {
140
+ render: () => (
141
+ <div className="flex flex-col gap-3 py-6">
142
+ <EventDayHeader dayNumber="08" weekday="Tuesday" meta="September · 1 event" size="responsive" />
143
+ <TimelineDay rows={1}>
144
+ <TimelineHour time="19:30" countLabel="1 event">
145
+ <Card ev={event("a", "Book club: Cărtărescu", "Cărturești", "19:30", 0)} priority />
146
+ </TimelineHour>
147
+ </TimelineDay>
148
+ </div>
149
+ ),
150
+ };
151
+
152
+ /**
153
+ * Two full days stacked, so the lane's pin and release can be watched at
154
+ * 1024 and 1440: scroll the canvas and the first day's poster holds until
155
+ * its column runs out, then the second day's takes over.
156
+ */
157
+ export const StickyLane: Story = {
158
+ render: () => (
159
+ <div className="flex flex-col gap-10 py-6">
160
+ {["03", "04"].map((day) => (
161
+ <div key={day} className="flex flex-col gap-3">
162
+ <EventDayHeader
163
+ dayNumber={day}
164
+ weekday={day === "03" ? "Thursday" : "Friday"}
165
+ meta="September · 7 events"
166
+ size="responsive"
167
+ />
168
+ <TimelineDay rows={4} laneTop="16px">
169
+ <TimelineHour time="18:00" role="hero" countLabel="1 event">
170
+ <Card
171
+ ev={event(`${day}-hero`, "Headliner of the night", "Form Space", "18:00", 50)}
172
+ emphasis="lg"
173
+ hideWhen="below-lg"
174
+ />
175
+ </TimelineHour>
176
+ {["19:00", "20:00", "21:00", "22:00"].map((hour) => (
177
+ <TimelineHour key={hour} time={hour} countLabel="2 events" layout="rows">
178
+ <Card
179
+ ev={event(`${day}-${hour}-1`, `Something at ${hour}`, "Klausen", hour, 20)}
180
+ density="row-lg"
181
+ />
182
+ <Card
183
+ ev={event(`${day}-${hour}-2`, `Something else at ${hour}`, "Joben", hour, 0)}
184
+ density="row-lg"
185
+ />
186
+ </TimelineHour>
187
+ ))}
188
+ </TimelineDay>
189
+ </div>
190
+ ))}
191
+ </div>
192
+ ),
193
+ };
194
+
195
+ export const Skeleton: Story = {
196
+ render: () => (
197
+ <div className="py-6">
198
+ <TimelineDaySkeleton hourCount={3} />
199
+ </div>
200
+ ),
201
+ };