@bearmenu/ui 0.8.6 → 0.8.8

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 (88) hide show
  1. package/dist/{chunk-637L7XQ3.js → chunk-3TNPSN4D.js} +28 -9
  2. package/dist/chunk-AHL45DD2.js +145 -0
  3. package/dist/chunk-IYDH5IU6.js +66 -0
  4. package/dist/chunk-OPVKL4VS.js +18 -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/category-deck.js +10 -1
  10. package/dist/components/command.d.ts +15 -15
  11. package/dist/components/coverflow.d.ts +11 -3
  12. package/dist/components/coverflow.js +34 -51
  13. package/dist/components/drift-rail.d.ts +24 -10
  14. package/dist/components/drift-rail.js +1 -1
  15. package/dist/components/event-card.d.ts +8 -0
  16. package/dist/components/event-card.js +68 -35
  17. package/dist/components/event-compact-row.d.ts +34 -6
  18. package/dist/components/event-compact-row.js +120 -38
  19. package/dist/components/event-day-header.js +3 -1
  20. package/dist/components/event-day-separator.d.ts +7 -1
  21. package/dist/components/event-day-separator.js +3 -2
  22. package/dist/components/event-hub-hero.d.ts +25 -4
  23. package/dist/components/event-hub-hero.js +89 -19
  24. package/dist/components/event-list-row.d.ts +19 -6
  25. package/dist/components/event-list-row.js +78 -34
  26. package/dist/components/event-types.d.ts +5 -1
  27. package/dist/components/hour-band.d.ts +38 -40
  28. package/dist/components/hour-band.js +55 -27
  29. package/dist/components/hour-group.js +1 -1
  30. package/dist/components/item.d.ts +2 -2
  31. package/dist/components/poster-tape.js +1 -1
  32. package/dist/components/poster-wall.d.ts +3 -3
  33. package/dist/components/poster-wall.js +73 -17
  34. package/dist/components/section-header.d.ts +16 -1
  35. package/dist/components/section-header.js +26 -7
  36. package/dist/components/snap-rail.d.ts +34 -0
  37. package/dist/components/snap-rail.js +51 -0
  38. package/dist/components/spinner.d.ts +2 -2
  39. package/dist/components/tag.d.ts +2 -2
  40. package/dist/components/text.d.ts +3 -3
  41. package/dist/components/timeline-band.d.ts +20 -2
  42. package/dist/components/timeline-band.js +1 -1
  43. package/dist/components/timeline-playhead.d.ts +26 -0
  44. package/dist/components/timeline-playhead.js +25 -0
  45. package/dist/components/weekend-panel.d.ts +5 -4
  46. package/dist/components/weekend-panel.js +85 -71
  47. package/dist/hooks/use-drag-swipe.d.ts +44 -0
  48. package/dist/hooks/use-drag-swipe.js +2 -0
  49. package/dist/hooks/use-media-query.d.ts +17 -0
  50. package/dist/hooks/use-media-query.js +2 -0
  51. package/package.json +1 -1
  52. package/src/components/category-deck.tsx +19 -8
  53. package/src/components/coverflow.test.tsx +28 -1
  54. package/src/components/coverflow.tsx +37 -43
  55. package/src/components/drift-rail.tsx +167 -42
  56. package/src/components/event-card.test.tsx +11 -0
  57. package/src/components/event-card.tsx +67 -14
  58. package/src/components/event-compact-row.stories.tsx +30 -0
  59. package/src/components/event-compact-row.test.tsx +100 -0
  60. package/src/components/event-compact-row.tsx +166 -40
  61. package/src/components/event-day-header.tsx +3 -1
  62. package/src/components/event-day-separator.tsx +8 -1
  63. package/src/components/event-hub-hero.stories.tsx +30 -0
  64. package/src/components/event-hub-hero.tsx +102 -30
  65. package/src/components/event-list-row.stories.tsx +34 -0
  66. package/src/components/event-list-row.test.tsx +53 -0
  67. package/src/components/event-list-row.tsx +135 -87
  68. package/src/components/event-types.ts +5 -1
  69. package/src/components/hour-band.stories.tsx +28 -5
  70. package/src/components/hour-band.tsx +90 -70
  71. package/src/components/poster-wall.test.tsx +28 -0
  72. package/src/components/poster-wall.tsx +96 -23
  73. package/src/components/section-header.stories.tsx +24 -0
  74. package/src/components/section-header.test.tsx +28 -0
  75. package/src/components/section-header.tsx +45 -7
  76. package/src/components/snap-rail.stories.tsx +79 -0
  77. package/src/components/snap-rail.test.tsx +18 -0
  78. package/src/components/snap-rail.tsx +67 -0
  79. package/src/components/timeline-band.tsx +52 -12
  80. package/src/components/timeline-playhead.stories.tsx +30 -0
  81. package/src/components/timeline-playhead.test.tsx +11 -0
  82. package/src/components/timeline-playhead.tsx +46 -0
  83. package/src/components/weekend-panel.test.tsx +43 -0
  84. package/src/components/weekend-panel.tsx +142 -108
  85. package/src/hooks/use-drag-swipe.test.tsx +66 -0
  86. package/src/hooks/use-drag-swipe.ts +105 -0
  87. package/src/hooks/use-media-query.ts +31 -0
  88. package/dist/chunk-GPH6L4DX.js +0 -55
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { memo, useState } from "react";
4
+ import { Radio } from "lucide-react";
4
5
  import { cn } from "../lib/utils";
5
6
  import { EventPosterFallback } from "./event-poster-fallback";
6
7
  import { Skeleton } from "./skeleton";
@@ -25,10 +26,25 @@ import {
25
26
  *
26
27
  * THIS ROW DOES NOT DRAW DIVIDERS; ITS CONTAINER DOES. `<HourGroup>` already
27
28
  * draws the rule under the hour heading, so a row-level top separator here
28
- * doubled it directly beneath every hour. `<EventListRow>` and
29
- * `<EventListRowWide>` are different: they stack in a plain list with no
30
- * other rule around them, so their own top separator is the only one there —
31
- * do not carry this rule over to them.
29
+ * doubled it directly beneath every hour; `<HourBand>` rules between its rows
30
+ * with a wrapper. `<EventListRow>` and `<EventListRowWide>` are different:
31
+ * they stack in a plain list with no other rule around them, so their own top
32
+ * separator is the only one there — do not carry this rule over to them.
33
+ *
34
+ * STATE LIVES ON THE META LINE. `isLive` adds the on-air chip and `isEnded`
35
+ * the ended chip beside the time and venue, so a row's state is read in the
36
+ * same glance as its facts. Ended dims the POSTER and moves the title to
37
+ * `--muted-foreground` — never row-level opacity, which composites the text
38
+ * below AA.
39
+ *
40
+ * THE PRICE IS ON THE LAST ROW, right-aligned beside the tags, not on the
41
+ * title line. Two bold things on one line compete — the title and a price
42
+ * both in 700 read as one shout — and the title is the thing the reader is
43
+ * scanning for. `showPrice` renders it (free in `--success`, paid in
44
+ * `--foreground`); without it the row keeps its trailing free pill for the
45
+ * hub's venue block. This is ONE row for the timeline and the list view:
46
+ * the list used to have its own, and the reader met two anatomies for the
47
+ * same object.
32
48
  *
33
49
  * Same contract as the rest: DS-owned view model, injected labels/formatters,
34
50
  * plain `<img>`, anchor whenever `href` is given.
@@ -49,7 +65,8 @@ export type EventCompactRowProps = {
49
65
  event: EventPreview;
50
66
  labels: EventCardLabels;
51
67
  formatters: EventFormatters;
52
- /** Drops the leading time from the sub line. */
68
+ /** Drops the leading time from the sub line. An empty `formatters.time`
69
+ * result (hour unknown) drops it the same way, without the prop. */
53
70
  hideTime?: boolean;
54
71
  /**
55
72
  * Replaces the default "time · venue" sub-line entirely. For surfaces where
@@ -67,6 +84,18 @@ export type EventCompactRowProps = {
67
84
  * rather than truncate, so a chip's own text is never cut mid-word. Cap
68
85
  * the count at the call site — this row does not. */
69
86
  tags?: string[];
87
+ /** Currently running — adds the `labels.liveNow` chip to the meta line. */
88
+ isLive?: boolean;
89
+ /** Already over — dims the poster, mutes the title, adds the `endedLabel` chip. */
90
+ isEnded?: boolean;
91
+ /** Chip text for an ended event, e.g. "Ended". Required to render the chip. */
92
+ endedLabel?: string;
93
+ /**
94
+ * Prints the fee on the meta line — `labels.free` in `--success`, a paid
95
+ * amount via `formatters.price` — instead of the trailing free pill. An
96
+ * unknown fee prints nothing, same as every sibling row.
97
+ */
98
+ showPrice?: boolean;
70
99
  size?: "sm" | "default";
71
100
  href?: string;
72
101
  linkComponent?: React.ComponentType<EventCompactRowLinkProps>;
@@ -85,6 +114,10 @@ export const EventCompactRow = memo(function EventCompactRow({
85
114
  hideTime,
86
115
  subtitle,
87
116
  tags,
117
+ isLive,
118
+ isEnded,
119
+ endedLabel,
120
+ showPrice,
88
121
  size = "default",
89
122
  href,
90
123
  linkComponent: LinkComponent = DefaultLink,
@@ -99,10 +132,23 @@ export const EventCompactRow = memo(function EventCompactRow({
99
132
  const fee = resolveEventEntryFee(event);
100
133
  const showPoster = !!event.poster_url && !imgError;
101
134
  const venue = event.place_name ?? event.location;
135
+ const time = formatters.time(event.starts_at);
136
+ const priceText =
137
+ fee.kind === "free"
138
+ ? labels.free
139
+ : fee.kind === "paid"
140
+ ? formatters.price(fee.amountRon)
141
+ : undefined;
142
+ const showEnded = isEnded && !!endedLabel;
143
+ const separator = (
144
+ <span aria-hidden className="text-border">
145
+ ·
146
+ </span>
147
+ );
102
148
 
103
149
  const body = (
104
150
  <>
105
- {/* 64x80 AND PORTRAIT, not a 34px square, and both parts matter.
151
+ {/* 58px WIDE AND PORTRAIT, not a 34px square, and both parts matter.
106
152
  91% of the posters this renders are portrait and ~57% are Instagram's
107
153
  4:5 — so a square crop kept the middle band and threw away the top and
108
154
  bottom, which on a flyer is exactly where the act, the venue and the
@@ -113,8 +159,22 @@ export const EventCompactRow = memo(function EventCompactRow({
113
159
 
114
160
  One size for both `sm` and `default`: no consumer passes `sm` today,
115
161
  and the failure is identical on a phone, so there is no second number
116
- to justify. `size` still drives type scale and pill height. */}
117
- <div className="relative aspect-[4/5] w-16 shrink-0 overflow-hidden rounded-[9px] bg-muted">
162
+ to justify. `size` still drives type scale and pill height.
163
+
164
+ Ended dims the poster to ~45%, not the row: the text keeps full ink
165
+ and moves to the muted tier instead, which stays above AA.
166
+
167
+ 64px at every width — the phone row briefly went to 58 and it was
168
+ too small to read a flyer by. Only the type scale and the padding are
169
+ phone-scoped: a 14px bold title and 11px of vertical padding below
170
+ `lg`; from `lg` the 13px semibold title and 8px padding the desktop
171
+ timeline and the hub's venue block already had. */}
172
+ <div
173
+ className={cn(
174
+ "relative aspect-[4/5] w-16 shrink-0 overflow-hidden rounded-[10px] bg-muted",
175
+ isEnded && "opacity-45",
176
+ )}
177
+ >
118
178
  {showPoster ? (
119
179
  // eslint-disable-next-line @next/next/no-img-element
120
180
  <img
@@ -126,7 +186,7 @@ export const EventCompactRow = memo(function EventCompactRow({
126
186
  onError={() => setImgError(true)}
127
187
  />
128
188
  ) : (
129
- // A bare `bg-muted` rectangle was survivable at 34px. At 64x80 beside
189
+ // A bare `bg-muted` rectangle was survivable at 34px. At 58x72 beside
130
190
  // real posters it reads as a broken image rather than an absent one —
131
191
  // so this row finally joins every sibling in the family in having a
132
192
  // designed fallback.
@@ -144,45 +204,97 @@ export const EventCompactRow = memo(function EventCompactRow({
144
204
  fire. */}
145
205
  <div
146
206
  className={cn(
147
- "font-semibold leading-[1.35]",
207
+ "leading-[1.3]",
148
208
  "transition-colors duration-150 motion-reduce:transition-none",
149
- isSm ? "text-[13.5px]" : "text-[13px]",
150
- (href || onClick) && "group-hover:text-primary"
209
+ isSm
210
+ ? "text-[14.5px] lg:text-[13.5px]"
211
+ : "text-[14px] lg:text-[13px]",
212
+ isEnded
213
+ ? "font-semibold text-muted-foreground"
214
+ : "font-bold text-foreground lg:font-semibold",
215
+ (href || onClick) && "group-hover:text-primary",
151
216
  )}
152
217
  >
153
218
  {event.title}
154
219
  </div>
155
- <div className="truncate text-[11.5px] text-muted-foreground">
220
+ {/* The meta line wraps rather than truncates once it carries chips:
221
+ a state chip pushed off the end would be the one thing the reader
222
+ needed. Venue is the only free-text part and it is what may wrap. */}
223
+ <div
224
+ className={cn(
225
+ "mt-1 flex items-center gap-1.5 text-[11.5px] text-muted-foreground",
226
+ isLive || showEnded ? "flex-wrap" : "truncate",
227
+ )}
228
+ >
156
229
  {subtitle ? (
157
230
  subtitle
158
231
  ) : (
159
232
  <>
160
- {!hideTime && (
233
+ {!hideTime && time && (
161
234
  <>
162
- <span className="font-mono tabular-nums">{formatters.time(event.starts_at)}</span>
163
- <span aria-hidden> · </span>
235
+ <span className="font-mono tabular-nums">{time}</span>
236
+ {separator}
164
237
  </>
165
238
  )}
166
- {venue}
239
+ <span className="min-w-0 truncate">{venue}</span>
167
240
  </>
168
241
  )}
242
+ {isLive && (
243
+ <Tag
244
+ size="sm"
245
+ variant="success"
246
+ className="shrink-0 whitespace-nowrap font-extrabold"
247
+ >
248
+ <Radio className="h-2.5 w-2.5" aria-hidden />
249
+ {labels.liveNow}
250
+ </Tag>
251
+ )}
252
+ {showEnded && (
253
+ <Tag
254
+ size="sm"
255
+ variant="default"
256
+ className="shrink-0 whitespace-nowrap font-extrabold"
257
+ >
258
+ {endedLabel}
259
+ </Tag>
260
+ )}
169
261
  </div>
170
- {tags && tags.length > 0 && (
171
- <TagGroup className="mt-1">
172
- {tags.map((tag) => (
173
- <Tag key={tag} size="sm">
174
- {tag}
175
- </Tag>
176
- ))}
177
- </TagGroup>
262
+ {/* The last row: tags on the left, the fee on the right. Either half
263
+ may be absent; the row exists when at least one is there. */}
264
+ {((tags && tags.length > 0) || (showPrice && priceText)) && (
265
+ <div className="mt-1.5 flex items-center gap-2">
266
+ {tags && tags.length > 0 ? (
267
+ <TagGroup className="min-w-0 flex-1">
268
+ {tags.map((tag) => (
269
+ <Tag key={tag} size="sm" variant="quiet">
270
+ {tag}
271
+ </Tag>
272
+ ))}
273
+ </TagGroup>
274
+ ) : (
275
+ <span className="flex-1" />
276
+ )}
277
+ {showPrice && priceText && (
278
+ <span
279
+ className={cn(
280
+ "shrink-0 text-[13px] tabular-nums",
281
+ fee.kind === "free"
282
+ ? "font-semibold text-success"
283
+ : "font-bold text-foreground",
284
+ )}
285
+ >
286
+ {priceText}
287
+ </span>
288
+ )}
289
+ </div>
178
290
  )}
179
291
  </div>
180
292
 
181
- {fee.kind === "free" && (
293
+ {!showPrice && fee.kind === "free" && (
182
294
  <span
183
295
  className={cn(
184
296
  "inline-flex shrink-0 items-center rounded-full border border-success/40 px-2 font-semibold text-success",
185
- isSm ? "h-[22px] text-[10.5px]" : "h-6 text-[11px]"
297
+ isSm ? "h-[22px] text-[10.5px]" : "h-6 text-[11px]",
186
298
  )}
187
299
  >
188
300
  {labels.free}
@@ -191,9 +303,9 @@ export const EventCompactRow = memo(function EventCompactRow({
191
303
  </>
192
304
  );
193
305
 
194
- // `items-start`, not `items-center`: an 80px poster is taller than a one-line
195
- // title over its sub-line, so centring floated the text in empty space.
196
- // `EventListRow` already made this call at its own 72px poster.
306
+ // `items-start`, not `items-center`: a 72px-tall poster is taller than a
307
+ // one-line title over its sub-line, so centring floated the text in empty
308
+ // space. `EventListRow` makes the same call.
197
309
  //
198
310
  // THE HOVER WASH IS `bg-foreground/5`, AND THE TOKEN CHOICE IS LOAD-BEARING.
199
311
  // This row renders on two different planes: the timeline puts it straight on
@@ -209,11 +321,11 @@ export const EventCompactRow = memo(function EventCompactRow({
209
321
  // same shape `EventIndexRow` uses. No transform: this is a zero-gap stacked
210
322
  // list, so a lift would overlap its neighbours rather than read as elevation.
211
323
  const rootClasses = cn(
212
- "group relative -mx-2.5 flex w-full items-start gap-[11px] rounded-lg px-2.5 py-2 text-left",
324
+ "group relative -mx-2.5 flex w-full items-start gap-3 rounded-lg px-2.5 py-[11px] text-left lg:gap-[11px] lg:py-2",
213
325
  "transition-colors duration-150 motion-reduce:transition-none",
214
326
  (href || onClick) &&
215
327
  "cursor-pointer hover:bg-foreground/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
216
- className
328
+ className,
217
329
  );
218
330
 
219
331
  if (href) {
@@ -221,7 +333,9 @@ export const EventCompactRow = memo(function EventCompactRow({
221
333
  <LinkComponent
222
334
  href={href}
223
335
  className={rootClasses}
224
- onClick={onClick ? (mouseEvent) => onClick(event, mouseEvent) : undefined}
336
+ onClick={
337
+ onClick ? (mouseEvent) => onClick(event, mouseEvent) : undefined
338
+ }
225
339
  >
226
340
  {body}
227
341
  </LinkComponent>
@@ -230,7 +344,11 @@ export const EventCompactRow = memo(function EventCompactRow({
230
344
 
231
345
  if (onClick) {
232
346
  return (
233
- <button type="button" className={rootClasses} onClick={(e) => onClick(event, e)}>
347
+ <button
348
+ type="button"
349
+ className={rootClasses}
350
+ onClick={(e) => onClick(event, e)}
351
+ >
234
352
  {body}
235
353
  </button>
236
354
  );
@@ -246,22 +364,30 @@ export type EventCompactRowSkeletonProps = {
246
364
  };
247
365
 
248
366
  /** Mirrors `<EventCompactRow>`'s geometry exactly so nothing shifts when data lands. */
249
- export function EventCompactRowSkeleton({ size = "default", className }: EventCompactRowSkeletonProps) {
367
+ export function EventCompactRowSkeleton({
368
+ size = "default",
369
+ className,
370
+ }: EventCompactRowSkeletonProps) {
250
371
  const isSm = size === "sm";
251
372
 
252
373
  return (
253
374
  <div
254
375
  aria-hidden="true"
255
376
  className={cn(
256
- "relative -mx-2.5 flex w-full items-start gap-[11px] rounded-lg px-2.5 py-2 text-left",
257
- className
377
+ "relative -mx-2.5 flex w-full items-start gap-3 rounded-lg px-2.5 py-[11px] text-left lg:gap-[11px] lg:py-2",
378
+ className,
258
379
  )}
259
380
  >
260
- <Skeleton className="aspect-[4/5] w-16 shrink-0 rounded-[9px]" />
381
+ <Skeleton className="aspect-[4/5] w-16 shrink-0 rounded-[10px]" />
261
382
 
262
383
  <div className="min-w-0 flex-1">
263
- <Skeleton className={cn(isSm ? "h-[13.5px]" : "h-[13px]", "w-2/5")} />
264
- <Skeleton className="mt-0.5 h-[11.5px] w-1/3" />
384
+ <Skeleton
385
+ className={cn(
386
+ isSm ? "h-[14.5px] lg:h-[13.5px]" : "h-[14px] lg:h-[13px]",
387
+ "w-3/5",
388
+ )}
389
+ />
390
+ <Skeleton className="mt-1 h-[11.5px] w-1/3" />
265
391
  </div>
266
392
  </div>
267
393
  );
@@ -76,7 +76,9 @@ export function EventDayHeader({
76
76
  <div
77
77
  className={cn(
78
78
  "font-black leading-[0.82] tracking-[-0.05em] tabular-nums",
79
- large ? "text-[58px]" : "text-[40px]"
79
+ // `sm` is the phone scale: 30px, the design's app day head. 40px
80
+ // was a page-scale number that took a third of a 390px row.
81
+ large ? "text-[58px]" : "text-[30px] leading-[0.86]"
80
82
  )}
81
83
  >
82
84
  {dayNumber}
@@ -28,6 +28,12 @@ import { Skeleton } from "./skeleton";
28
28
  */
29
29
 
30
30
  export type EventDaySeparatorProps = {
31
+ /**
32
+ * `always` (default) draws the trailing rule at every width. `lg` drops it
33
+ * below `lg`, where the list is an app sheet whose day heads are bold
34
+ * labels alone — the rows below rule themselves — and keeps it from `lg`.
35
+ */
36
+ rule?: "always" | "lg";
31
37
  label: string;
32
38
  count?: string;
33
39
  size?: "sm" | "default";
@@ -42,6 +48,7 @@ export function EventDaySeparator({
42
48
  size = "default",
43
49
  as: Tag = "h2",
44
50
  className,
51
+ rule = "always",
45
52
  }: EventDaySeparatorProps) {
46
53
  return (
47
54
  <div
@@ -61,7 +68,7 @@ export function EventDaySeparator({
61
68
  </span>
62
69
  )}
63
70
 
64
- <Separator className="flex-1" />
71
+ <Separator className={cn("flex-1", rule === "lg" && "hidden lg:block")} />
65
72
  </div>
66
73
  );
67
74
  }
@@ -19,6 +19,7 @@ const meta: Meta<typeof EventHubHero> = {
19
19
  component: EventHubHero,
20
20
  tags: ["autodocs"],
21
21
  args: {
22
+ statsLabel: "Key figures",
22
23
  eyebrow: "Updated today",
23
24
  title: "Everything happening in Bucharest",
24
25
  intro:
@@ -64,3 +65,32 @@ export const NoAside: Story = {
64
65
  export const Skeleton: Story = {
65
66
  render: () => <EventHubHeroSkeleton aside={<div className="aspect-[3/2] rounded-xl bg-muted" />} />,
66
67
  };
68
+
69
+ /**
70
+ * Below `lg` the hero is a masthead: headline, one sub-line, and the figures
71
+ * as a chip rail bleeding to the screen edge. The `live` figure takes the
72
+ * success border and value. View at 390px.
73
+ */
74
+ export const Masthead: Story = {
75
+ args: {
76
+ statsLabel: "Key figures",
77
+ eyebrow: "Updated today · next 14 days",
78
+ title: "Events in Cluj-Napoca",
79
+ intro:
80
+ "Everything on across the city for the next two weeks, from tonight's quizzes to the weekend's big rooms.",
81
+ stats: [
82
+ { value: "3", label: "on right now", tone: "live" },
83
+ { value: "76", label: "this fortnight" },
84
+ { value: "34", label: "venues" },
85
+ { value: "12", label: "free entry" },
86
+ { value: "18", label: "past midnight" },
87
+ ],
88
+ },
89
+ decorators: [
90
+ (Story) => (
91
+ <div className="w-[390px] bg-surface-2 px-4 pt-4">
92
+ <Story />
93
+ </div>
94
+ ),
95
+ ],
96
+ };
@@ -5,9 +5,20 @@ import { cn } from "../lib/utils";
5
5
  import { Skeleton } from "./skeleton";
6
6
 
7
7
  /**
8
- * EventHubHero — the hub's opening block: an eyebrow, a big headline, an
9
- * intro line and a stats row on the left; an arbitrary slot (the poster
10
- * stack, in the source design) on the right. Stacks on mobile.
8
+ * EventHubHero — the hub's opening block. On desktop: an eyebrow, a big
9
+ * headline, an intro paragraph and a stats row on the left; an arbitrary slot
10
+ * (the poster stack, in the source design) on the right.
11
+ *
12
+ * BELOW `lg` IT IS A MASTHEAD, NOT A HERO. One headline line, the eyebrow
13
+ * demoted to a sub-line beneath it, no intro, and the figures as a swipeable
14
+ * rail of chips that bleeds to the screen edge. The first real content lands
15
+ * roughly 250px sooner than under the web-shaped hero — on a phone the page
16
+ * should show, not narrate. Same markup at every width; only the classes
17
+ * change, so the copy is authored once.
18
+ *
19
+ * A stat with `tone: "live"` is the one that is happening right now: its
20
+ * chip takes a `--success` border and value, the same live colour every
21
+ * events surface uses.
11
22
  *
12
23
  * All copy and stat values arrive pre-formatted — the DS holds no `Intl`
13
24
  * locale and does not format numbers.
@@ -16,6 +27,8 @@ import { Skeleton } from "./skeleton";
16
27
  export type EventHubHeroStat = {
17
28
  value: string;
18
29
  label: string;
30
+ /** `live` marks the figure that is happening right now. */
31
+ tone?: "default" | "live";
19
32
  };
20
33
 
21
34
  export type EventHubHeroProps = {
@@ -24,12 +37,28 @@ export type EventHubHeroProps = {
24
37
  title: string;
25
38
  intro: string;
26
39
  stats: EventHubHeroStat[];
40
+ /**
41
+ * Accessible name for the stats rail, e.g. "Key figures". When given, the
42
+ * rail is a named, focusable region so a keyboard can reach chips clipped
43
+ * by the phone's edge. Optional so existing consumers keep compiling — but
44
+ * pass it: without a name the rail cannot be focusable, and a phone reader
45
+ * on a keyboard cannot reach the last figures.
46
+ */
47
+ statsLabel?: string;
27
48
  /** The right-column slot — the poster stack in the source design. */
28
49
  aside?: React.ReactNode;
29
50
  className?: string;
30
51
  };
31
52
 
32
- export function EventHubHero({ eyebrow, title, intro, stats, aside, className }: EventHubHeroProps) {
53
+ export function EventHubHero({
54
+ eyebrow,
55
+ title,
56
+ intro,
57
+ stats,
58
+ statsLabel,
59
+ aside,
60
+ className,
61
+ }: EventHubHeroProps) {
33
62
  return (
34
63
  <div
35
64
  className={cn(
@@ -37,30 +66,70 @@ export function EventHubHero({ eyebrow, title, intro, stats, aside, className }:
37
66
  className
38
67
  )}
39
68
  >
40
- <div className="flex flex-col gap-4">
41
- <p className="text-[11.5px] font-extrabold tracking-[0.12em] text-muted-foreground">
69
+ <div className="flex flex-col gap-1.5 lg:gap-4">
70
+ {/* Eyebrow above the title on desktop; the sub-line beneath it on a
71
+ phone. `order` swaps the position, the classes swap the voice. */}
72
+ <p
73
+ className={cn(
74
+ "order-2 text-[13px] text-muted-foreground",
75
+ "lg:order-none lg:text-[11.5px] lg:font-extrabold lg:tracking-[0.12em]"
76
+ )}
77
+ >
42
78
  {eyebrow}
43
79
  </p>
44
80
 
45
- <h1 className="text-[31px] font-black leading-[1.05] tracking-[-0.035em] text-foreground md:text-[42px] md:tracking-[-0.04em]">
81
+ <h1
82
+ className={cn(
83
+ "order-1 text-[26px] font-black leading-[1.1] tracking-[-0.035em] text-foreground",
84
+ "lg:order-none lg:text-[42px] lg:leading-[1.05] lg:tracking-[-0.04em]"
85
+ )}
86
+ >
46
87
  {title}
47
88
  </h1>
48
89
 
49
- <p className="max-w-[520px] text-[14px] leading-[1.6] text-muted-foreground md:text-[15px] md:leading-[1.65]">
90
+ <p className="hidden max-w-[520px] text-[15px] leading-[1.65] text-muted-foreground lg:order-none lg:block">
50
91
  {intro}
51
92
  </p>
52
93
 
53
- <div className="flex gap-[22px] pt-1.5 md:gap-[30px]">
54
- {stats.map((stat) => (
55
- <div key={stat.label} className="flex flex-col">
56
- <span className="text-[24px] font-black leading-none tracking-[-0.03em] tabular-nums text-foreground md:text-[30px]">
57
- {stat.value}
58
- </span>
59
- <span className="mt-1 text-[11px] text-muted-foreground md:mt-[5px] md:text-[12.5px]">
60
- {stat.label}
61
- </span>
62
- </div>
63
- ))}
94
+ {/* Figures. A chip rail below `lg` — `-mx-4 px-4` runs it under the
95
+ page gutter so the last chip is cut by the screen edge, which is
96
+ what says "swipe"; inline figures from `lg`. */}
97
+ <div
98
+ role={statsLabel ? "region" : undefined}
99
+ aria-label={statsLabel}
100
+ tabIndex={statsLabel ? 0 : undefined}
101
+ className={cn(
102
+ "order-3 -mx-4 flex gap-2 overflow-x-auto px-4 pt-2 scrollbar-hide",
103
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
104
+ "lg:order-none lg:mx-0 lg:gap-[30px] lg:overflow-visible lg:px-0 lg:pt-1.5"
105
+ )}
106
+ >
107
+ {stats.map((stat) => {
108
+ const isLive = stat.tone === "live";
109
+ return (
110
+ <div
111
+ key={stat.label}
112
+ className={cn(
113
+ "flex min-w-[106px] shrink-0 flex-col rounded-[14px] border bg-card px-[13px] py-2.5",
114
+ isLive ? "border-success/40" : "border-border",
115
+ "lg:min-w-0 lg:rounded-none lg:border-0 lg:bg-transparent lg:p-0"
116
+ )}
117
+ >
118
+ <span
119
+ className={cn(
120
+ "text-[19px] font-black leading-none tracking-[-0.02em] tabular-nums",
121
+ isLive ? "text-success" : "text-foreground",
122
+ "lg:text-[30px] lg:tracking-[-0.03em]"
123
+ )}
124
+ >
125
+ {stat.value}
126
+ </span>
127
+ <span className="mt-0.5 text-[11px] text-muted-foreground lg:mt-[5px] lg:text-[12.5px]">
128
+ {stat.label}
129
+ </span>
130
+ </div>
131
+ );
132
+ })}
64
133
  </div>
65
134
  </div>
66
135
 
@@ -84,21 +153,24 @@ export function EventHubHeroSkeleton({ aside, className }: EventHubHeroSkeletonP
84
153
  className
85
154
  )}
86
155
  >
87
- <div className="flex flex-col gap-4">
88
- <Skeleton className="h-[11.5px] w-28" />
156
+ <div className="flex flex-col gap-1.5 lg:gap-4">
157
+ <Skeleton className="order-2 h-[13px] w-40 lg:order-none lg:h-[11.5px] lg:w-28" />
89
158
 
90
- <Skeleton className="h-[31px] w-full max-w-[420px] md:h-[42px]" />
159
+ <Skeleton className="order-1 h-[26px] w-full max-w-[420px] lg:order-none lg:h-[42px]" />
91
160
 
92
- <div className="flex max-w-[520px] flex-col gap-1.5">
93
- <Skeleton className="h-[14px] w-full md:h-[15px]" />
94
- <Skeleton className="h-[14px] w-4/5 md:h-[15px]" />
161
+ <div className="hidden max-w-[520px] flex-col gap-1.5 lg:flex">
162
+ <Skeleton className="h-[15px] w-full" />
163
+ <Skeleton className="h-[15px] w-4/5" />
95
164
  </div>
96
165
 
97
- <div className="flex gap-[22px] pt-1.5 md:gap-[30px]">
98
- {Array.from({ length: 3 }, (_, index) => (
99
- <div key={index} className="flex flex-col">
100
- <Skeleton className="h-[24px] w-10 md:h-[30px]" />
101
- <Skeleton className="mt-1 h-[11px] w-16 md:mt-[5px] md:h-[12.5px]" />
166
+ <div className="order-3 -mx-4 flex gap-2 overflow-hidden px-4 pt-2 lg:order-none lg:mx-0 lg:gap-[30px] lg:px-0 lg:pt-1.5">
167
+ {Array.from({ length: 4 }, (_, index) => (
168
+ <div
169
+ key={index}
170
+ className="flex min-w-[106px] shrink-0 flex-col rounded-[14px] border border-border bg-card px-[13px] py-2.5 lg:min-w-0 lg:rounded-none lg:border-0 lg:bg-transparent lg:p-0"
171
+ >
172
+ <Skeleton className="h-[19px] w-8 lg:h-[30px] lg:w-10" />
173
+ <Skeleton className="mt-0.5 h-[11px] w-16 lg:mt-[5px] lg:h-[12.5px]" />
102
174
  </div>
103
175
  ))}
104
176
  </div>
@@ -157,3 +157,37 @@ export const ADay: Story = {
157
157
  </div>
158
158
  ),
159
159
  };
160
+
161
+ /**
162
+ * Inside a dated group the heading names the day, so the row drops its badge
163
+ * and leads with the poster. This is the list view's shipped shape.
164
+ */
165
+ export const InDatedGroup: Story = {
166
+ render: () => (
167
+ <div className="flex flex-col">
168
+ <EventListRow
169
+ event={event("g1", "Hat Design Workshop with Matira Hats", "Del Mars", "2026-09-07T17:00:00", 795)}
170
+ labels={labels}
171
+ formatters={formatters}
172
+ tags={["Workshop"]}
173
+ hideDate
174
+ hideRule
175
+ />
176
+ <EventListRow
177
+ event={event("g2", "International Quiz at Yolka", "Yolka", "2026-09-07T19:00:00", 0)}
178
+ labels={labels}
179
+ formatters={formatters}
180
+ tags={["Drinks", "Games"]}
181
+ isLive
182
+ hideDate
183
+ />
184
+ <EventListRow
185
+ event={event("g3", "Klausen Quiz by GOtrivia", "Klausen Pubhouse", "2026-09-07T20:00:00", 20)}
186
+ labels={labels}
187
+ formatters={formatters}
188
+ tags={["Drinks", "Games"]}
189
+ hideDate
190
+ />
191
+ </div>
192
+ ),
193
+ };