@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
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { EventListRow } from "./event-list-row";
4
+ import type { EventDateBadgeFormatters, EventListRowLabels, EventPreview } from "./event-types";
5
+
6
+ const labels: EventListRowLabels = { free: "Free", liveNow: "Live now", soldOut: "Sold out" };
7
+
8
+ const formatters: EventDateBadgeFormatters = {
9
+ time: () => "20:00",
10
+ day: () => "Mon, Sep 7",
11
+ price: (amount) => `${amount} RON`,
12
+ dayNumber: () => "07",
13
+ monthShort: () => "SEP",
14
+ };
15
+
16
+ const event: EventPreview = {
17
+ id: "e1",
18
+ title: "Klausen Quiz by GOtrivia",
19
+ location: "Klausen Pubhouse",
20
+ starts_at: "2026-09-07T20:00:00Z",
21
+ place_name: "Klausen Pubhouse",
22
+ entry_fee: 20,
23
+ };
24
+
25
+ describe("EventListRow", () => {
26
+ it("renders the date badge by default and drops it with hideDate", () => {
27
+ const { rerender } = render(<EventListRow event={event} labels={labels} formatters={formatters} />);
28
+ expect(screen.getByText("07")).toBeInTheDocument();
29
+ expect(screen.getByText("SEP")).toBeInTheDocument();
30
+
31
+ rerender(<EventListRow event={event} labels={labels} formatters={formatters} hideDate />);
32
+ expect(screen.queryByText("07")).not.toBeInTheDocument();
33
+ expect(screen.queryByText("SEP")).not.toBeInTheDocument();
34
+ });
35
+
36
+ it("keeps the fee, the time and the venue with the badge gone", () => {
37
+ render(<EventListRow event={event} labels={labels} formatters={formatters} hideDate />);
38
+ expect(screen.getByText("20 RON")).toBeInTheDocument();
39
+ expect(screen.getByText("20:00")).toBeInTheDocument();
40
+ expect(screen.getByText("Klausen Pubhouse")).toBeInTheDocument();
41
+ });
42
+
43
+ it("replaces the fee with the sold-out chip", () => {
44
+ render(<EventListRow event={event} labels={labels} formatters={formatters} isSoldOut />);
45
+ expect(screen.getByText("Sold out")).toBeInTheDocument();
46
+ expect(screen.queryByText("20 RON")).not.toBeInTheDocument();
47
+ });
48
+
49
+ it("shows the live chip when isLive", () => {
50
+ render(<EventListRow event={event} labels={labels} formatters={formatters} isLive hideDate />);
51
+ expect(screen.getByText("Live now")).toBeInTheDocument();
52
+ });
53
+ });
@@ -34,11 +34,17 @@ import {
34
34
  * has the width for one and its row root is a container rather than a single
35
35
  * anchor. If a CTA comes back here, it has to be a real one, not a span.
36
36
  *
37
- * NOTE ON WEIGHT: nothing in this row is heavier than 600, and only the title
38
- * and the date badge reach it. Eight of these stack per screen, and at that
39
- * density hierarchy comes from size and ink tier, not from weight — a field of
40
- * bold text has no foreground. `Free` is the one exception (600 plus
41
- * `text-success`), because it is an inducement rather than an attribute.
37
+ * NOTE ON WEIGHT: the title and the fee are the only bold things in this row.
38
+ * Eight of these stack per screen, and at that density hierarchy comes from
39
+ * size and ink tier as much as weight — so everything below the title line is
40
+ * regular, muted, and small. The fee is bold because it is the row's one
41
+ * inducement, and it sits on the TITLE line, right-aligned, where a price is
42
+ * read against the thing it buys rather than lost among the meta.
43
+ *
44
+ * NOTE ON THE DATE BADGE: `hideDate` drops it. Inside a dated group — the list
45
+ * view's "Today · Monday 7 September" separators — every row's badge repeats
46
+ * the heading eight times per screen; the heading carries the date and the
47
+ * rows carry the time. The badge stays available for undated lists.
42
48
  *
43
49
  * Same contract as the rest: DS-owned view model, injected labels and
44
50
  * formatters, plain `<img>`, anchor whenever `href` is given.
@@ -86,6 +92,11 @@ export type EventListRowProps = {
86
92
  * line. Same prop and same reason as `EventListRowWide` and `EventIndexRow`.
87
93
  */
88
94
  hideRule?: boolean;
95
+ /**
96
+ * Drops the leading date badge. Pass it when the rows sit under a dated
97
+ * heading that already names the day; the row then leads with its poster.
98
+ */
99
+ hideDate?: boolean;
89
100
  };
90
101
 
91
102
  export const EventListRow = memo(function EventListRow({
@@ -102,6 +113,7 @@ export const EventListRow = memo(function EventListRow({
102
113
  tags,
103
114
  fallback = <EventPosterFallback />,
104
115
  hideRule,
116
+ hideDate,
105
117
  }: EventListRowProps) {
106
118
  const [imgError, setImgError] = useState(false);
107
119
 
@@ -132,28 +144,35 @@ export const EventListRow = memo(function EventListRow({
132
144
  `isLive` alone, so the only filled badge on screen is the live one.
133
145
  The geometry is identical either way, so nothing shifts when the
134
146
  tint appears. */}
135
- <div
136
- className={cn("w-[42px] shrink-0 rounded-[9px] py-1.5 text-center", isLive && "bg-success/10")}
137
- >
138
- <div
139
- className={cn(
140
- "text-sm font-semibold leading-none tabular-nums",
141
- isLive ? "text-success" : "text-foreground"
142
- )}
143
- >
144
- {formatters.dayNumber(event.starts_at)}
145
- </div>
147
+ {!hideDate && (
146
148
  <div
147
149
  className={cn(
148
- "mt-[3px] text-[10px] font-semibold tracking-[0.06em]",
149
- isLive ? "text-success" : "text-muted-foreground"
150
+ "w-[42px] shrink-0 rounded-[9px] py-1.5 text-center",
151
+ isLive && "bg-success/10",
150
152
  )}
151
153
  >
152
- {formatters.monthShort(event.starts_at)}
154
+ <div
155
+ className={cn(
156
+ "text-sm font-semibold leading-none tabular-nums",
157
+ isLive ? "text-success" : "text-foreground",
158
+ )}
159
+ >
160
+ {formatters.dayNumber(event.starts_at)}
161
+ </div>
162
+ <div
163
+ className={cn(
164
+ "mt-[3px] text-[10px] font-semibold tracking-[0.06em]",
165
+ isLive ? "text-success" : "text-muted-foreground",
166
+ )}
167
+ >
168
+ {formatters.monthShort(event.starts_at)}
169
+ </div>
153
170
  </div>
154
- </div>
171
+ )}
155
172
 
156
- <div className="aspect-square w-[72px] shrink-0 overflow-hidden rounded-[9px] bg-muted">
173
+ {/* Portrait, for the same reason `EventCompactRow` gives: most posters
174
+ are 4:5 flyers and a square crop throws away the act and the date. */}
175
+ <div className="aspect-[4/5] w-[58px] shrink-0 overflow-hidden rounded-[10px] bg-muted">
157
176
  {showPoster ? (
158
177
  // eslint-disable-next-line @next/next/no-img-element
159
178
  <img
@@ -170,41 +189,30 @@ export const EventListRow = memo(function EventListRow({
170
189
  </div>
171
190
 
172
191
  <div className="min-w-0 flex-1">
173
- {/* Full title, never clamped. The row is `items-start`, so extra lines
174
- grow the row downward past the 72px poster instead of squeezing
175
- anything. */}
176
- <div className="text-[15px] font-semibold leading-[1.3] tracking-[-0.01em]">{event.title}</div>
177
-
178
- {/* WHEN and WHERE, with the fee pinned right. Time leads because a
179
- time-sorted day group scans down that column. No `·` between time
180
- and venue: the `MapPin` is the field marker, so a dot as well is
181
- two separators doing one job — the glyph costs 6px net, not 16.
182
- The pin is the same one the filter rail uses for VENUE. */}
183
- <div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
184
- <span className="shrink-0 tabular-nums text-foreground">
185
- {formatters.time(event.starts_at)}
186
- </span>
187
-
188
- <span className="flex min-w-0 flex-1 items-center gap-1">
189
- <MapPin className="h-3 w-3 shrink-0" aria-hidden />
190
- <span className="truncate">{venue}</span>
191
- </span>
192
-
193
- {/* A sold-out event's price is not a fact anyone needs, so the chip
194
- takes the fee's slot rather than sitting beside it. The fee stays
195
- on THIS line rather than joining the chips: it is the row's only
196
- inducement, and among decorative chips it would either become one
197
- or duplicate the live chip's green with a second meaning. */}
192
+ {/* TITLE with the fee pinned right. Full title, never clamped: the
193
+ row is `items-start`, so extra lines grow the row downward past
194
+ the poster instead of squeezing anything. The fee is the one bold
195
+ thing beside it; a sold-out event's price is not a fact anyone
196
+ needs, so the chip takes the fee's slot rather than sitting
197
+ beside it. */}
198
+ <div className="flex items-start gap-2.5">
199
+ <div className="min-w-0 flex-1 text-[14px] font-bold leading-[1.3] tracking-[-0.01em]">
200
+ {event.title}
201
+ </div>
198
202
  {isSoldOut ? (
199
- <Tag size="sm" variant="quiet" className="shrink-0 whitespace-nowrap">
203
+ <Tag
204
+ size="sm"
205
+ variant="quiet"
206
+ className="shrink-0 whitespace-nowrap"
207
+ >
200
208
  {labels.soldOut}
201
209
  </Tag>
202
210
  ) : (
203
211
  priceText && (
204
212
  <span
205
213
  className={cn(
206
- "shrink-0 tabular-nums",
207
- fee.kind === "free" && "font-semibold text-success"
214
+ "shrink-0 text-[13.5px] font-extrabold tabular-nums",
215
+ fee.kind === "free" ? "text-success" : "text-foreground",
208
216
  )}
209
217
  >
210
218
  {priceText}
@@ -213,32 +221,56 @@ export const EventListRow = memo(function EventListRow({
213
221
  )}
214
222
  </div>
215
223
 
216
- {/* WHAT. Status first, then attributes. Live is the only FILLED chip
217
- on the surface, exactly as it is the only filled date badge —
218
- under deuteranopia the green/grey hues converge, so the fill and
219
- the `Radio` glyph are what actually separate it, not the hue.
224
+ {/* WHEN and WHERE, then the live chip. Time leads because a
225
+ time-sorted day group scans down that column. The `MapPin` is the
226
+ venue's field marker, so the dot before it is the only separator.
227
+ Live is the only FILLED chip on the surface under deuteranopia
228
+ the green/grey hues converge, so the fill and the `Radio` glyph
229
+ are what actually separate it, not the hue. */}
230
+ <div className="mt-1 flex items-center gap-1.5 text-[11.5px] text-muted-foreground">
231
+ <span className="shrink-0 font-mono tabular-nums">
232
+ {formatters.time(event.starts_at)}
233
+ </span>
234
+ <span aria-hidden className="text-border">
235
+ ·
236
+ </span>
237
+ <span className="flex min-w-0 items-center gap-1">
238
+ <MapPin className="h-3 w-3 shrink-0" aria-hidden />
239
+ <span className="truncate">{venue}</span>
240
+ </span>
241
+ {isLive && (
242
+ <Tag
243
+ size="sm"
244
+ variant="success"
245
+ className="shrink-0 whitespace-nowrap font-extrabold"
246
+ >
247
+ <Radio className="h-2.5 w-2.5" aria-hidden />
248
+ {labels.liveNow}
249
+ </Tag>
250
+ )}
251
+ </div>
220
252
 
221
- `h-[22px] overflow-hidden` is the overflow mechanism: `TagGroup`
222
- already wraps, so a chip that does not fit moves to a second line
223
- that is clipped away, and it disappears WHOLE. No half-chips, no
224
- ellipsis inside a pill, and it is width-safe for caller strings of
225
- any length — which a fixed count is not. Deliberately no "+2"
226
- counter: nothing in this row is tappable, so a counter would
227
- promise a disclosure that does not exist, eight times per screen.
253
+ {/* WHAT. `h-[22px] overflow-hidden` is the overflow mechanism:
254
+ `TagGroup` already wraps, so a chip that does not fit moves to a
255
+ second line that is clipped away, and it disappears WHOLE. No
256
+ half-chips, no ellipsis inside a pill, and it is width-safe for
257
+ caller strings of any length — which a fixed count is not.
258
+ Deliberately no "+2" counter: nothing in this row is tappable, so
259
+ a counter would promise a disclosure that does not exist, eight
260
+ times per screen.
228
261
 
229
262
  The line collapses when there is nothing to say. A "Tags" filler
230
263
  would be the same mistake as "Price TBA" — the row's spare slot
231
264
  announcing an absence. */}
232
- {(isLive || (tags && tags.length > 0)) && (
265
+ {tags && tags.length > 0 && (
233
266
  <TagGroup className="mt-1.5 h-[22px] overflow-hidden">
234
- {isLive && (
235
- <Tag size="sm" variant="success" className="shrink-0 whitespace-nowrap">
236
- <Radio className="h-2.5 w-2.5" aria-hidden />
237
- {labels.liveNow}
238
- </Tag>
239
- )}
240
- {tags?.map((tag) => (
241
- <Tag key={tag} size="sm" variant="quiet" className="shrink-0 whitespace-nowrap">
267
+ {tags.map((tag) => (
268
+ <Tag
269
+ key={tag}
270
+ size="sm"
271
+ variant="quiet"
272
+ className="shrink-0 whitespace-nowrap"
273
+ >
242
274
  {tag}
243
275
  </Tag>
244
276
  ))}
@@ -249,14 +281,14 @@ export const EventListRow = memo(function EventListRow({
249
281
  );
250
282
 
251
283
  const rootClasses = cn(
252
- // `py-3`, not `py-3.5`: the chip line adds a third line back, and 8px of
253
- // padding pays for most of it. Rows with a one-line title and no chips are
254
- // still poster-driven and end up SHORTER than before.
255
- "relative flex w-full items-start gap-3 py-3 text-left",
284
+ // `py-[11px]`: the poster is 72px tall and a one-line title, meta and a
285
+ // chip line come to ~70px, so the row is poster-driven at ~94px about
286
+ // eight per screen, which is the list view's reason to exist.
287
+ "relative flex w-full items-start gap-3 py-[11px] text-left",
256
288
  isSoldOut && "opacity-60",
257
289
  (href || onClick) &&
258
290
  "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
259
- className
291
+ className,
260
292
  );
261
293
 
262
294
  if (href) {
@@ -264,7 +296,9 @@ export const EventListRow = memo(function EventListRow({
264
296
  <LinkComponent
265
297
  href={href}
266
298
  className={rootClasses}
267
- onClick={onClick ? (mouseEvent) => onClick(event, mouseEvent) : undefined}
299
+ onClick={
300
+ onClick ? (mouseEvent) => onClick(event, mouseEvent) : undefined
301
+ }
268
302
  >
269
303
  {body}
270
304
  </LinkComponent>
@@ -273,7 +307,11 @@ export const EventListRow = memo(function EventListRow({
273
307
 
274
308
  if (onClick) {
275
309
  return (
276
- <button type="button" className={rootClasses} onClick={(e) => onClick(event, e)}>
310
+ <button
311
+ type="button"
312
+ className={rootClasses}
313
+ onClick={(e) => onClick(event, e)}
314
+ >
277
315
  {body}
278
316
  </button>
279
317
  );
@@ -285,6 +323,8 @@ export const EventListRow = memo(function EventListRow({
285
323
  export type EventListRowSkeletonProps = {
286
324
  /** Matches `EventListRowProps["hideRule"]` — drop the top rule on the first row of a day group. */
287
325
  hideRule?: boolean;
326
+ /** Matches `EventListRowProps["hideDate"]`. */
327
+ hideDate?: boolean;
288
328
  /** Reserve the chip line. Matches the real row, where it collapses on events with no live status and no tags. */
289
329
  showTags?: boolean;
290
330
  className?: string;
@@ -293,34 +333,42 @@ export type EventListRowSkeletonProps = {
293
333
  /** Mirrors `<EventListRow>`'s geometry exactly so nothing shifts when data lands. */
294
334
  export function EventListRowSkeleton({
295
335
  hideRule,
336
+ hideDate,
296
337
  showTags = true,
297
338
  className,
298
339
  }: EventListRowSkeletonProps) {
299
340
  return (
300
341
  <div
301
342
  aria-hidden="true"
302
- className={cn("relative flex w-full items-start gap-3 py-3 text-left", className)}
343
+ className={cn(
344
+ "relative flex w-full items-start gap-3 py-[11px] text-left",
345
+ className,
346
+ )}
303
347
  >
304
348
  {!hideRule && <Separator className="absolute inset-x-0 top-0" />}
305
349
 
306
- <div className="w-[42px] shrink-0 rounded-[9px] py-1.5 text-center">
307
- <Skeleton className="mx-auto h-4 w-5" />
308
- <Skeleton className="mx-auto mt-[3px] h-[10px] w-6" />
309
- </div>
350
+ {!hideDate && (
351
+ <div className="w-[42px] shrink-0 rounded-[9px] py-1.5 text-center">
352
+ <Skeleton className="mx-auto h-4 w-5" />
353
+ <Skeleton className="mx-auto mt-[3px] h-[10px] w-6" />
354
+ </div>
355
+ )}
310
356
 
311
- <Skeleton className="aspect-square w-[72px] shrink-0 rounded-[9px]" />
357
+ <Skeleton className="aspect-[4/5] w-[58px] shrink-0 rounded-[10px]" />
312
358
 
313
359
  <div className="min-w-0 flex-1">
314
- <div className="space-y-1">
315
- <Skeleton className="h-[15px] w-full" />
316
- <Skeleton className="h-[15px] w-2/3" />
360
+ <div className="flex items-start gap-2.5">
361
+ <div className="min-w-0 flex-1 space-y-1">
362
+ <Skeleton className="h-[14px] w-full" />
363
+ <Skeleton className="h-[14px] w-2/3" />
364
+ </div>
365
+ <Skeleton className="h-[13.5px] w-12 shrink-0" />
317
366
  </div>
318
367
 
319
- <div className="mt-1 flex items-center gap-2">
368
+ <div className="mt-1 flex items-center gap-1.5">
320
369
  <Skeleton className="h-3 w-10" />
321
370
  <Skeleton className="h-3 w-3 shrink-0 rounded-full" />
322
371
  <Skeleton className="h-3 w-24" />
323
- <Skeleton className="ml-auto h-3 w-12" />
324
372
  </div>
325
373
 
326
374
  {showTags && (
@@ -76,7 +76,11 @@ export function resolveEventEntryFee(event: EventPreview): EventEntryFee {
76
76
  * locale and no currency of its own.
77
77
  */
78
78
  export type EventFormatters = {
79
- /** e.g. `"20:00"` */
79
+ /**
80
+ * e.g. `"20:00"`. Return `""` when the event's hour is not known: the card
81
+ * and the compact row then print the day (or the venue) ALONE, with no
82
+ * separator — never a placeholder word and never "00:00".
83
+ */
80
84
  time: (isoDateTime: string) => string;
81
85
  /** e.g. `"Sat, Sep 5"` — used when a card appears outside its own day section. */
82
86
  day: (isoDateTime: string) => string;
@@ -1,6 +1,7 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { HourBand, HourBandSkeleton } from "./hour-band";
3
3
  import { EventCard } from "./event-card";
4
+ import { EventCompactRow } from "./event-compact-row";
4
5
  import type { EventCardLabels, EventFormatters, EventPreview } from "./event-types";
5
6
 
6
7
  const labels: EventCardLabels = { free: "Free", liveNow: "Live now" };
@@ -35,7 +36,7 @@ const meta: Meta<typeof HourBand> = {
35
36
  docs: {
36
37
  description: {
37
38
  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
+ "One hour of the mobile timeline: a 58px gutter with a filled hour chip and a hairline dropping from beneath it, and that hour's rows beside it, ruled between. The rows carry no timestamp of their own — the band is the timestamp, which is why cards render with `hideWhen` and compact rows with `hideTime`.",
39
40
  },
40
41
  },
41
42
  },
@@ -66,7 +67,7 @@ export const Default: Story = {
66
67
  },
67
68
  };
68
69
 
69
- /** The live hour: green-bordered puck, NOW label, and the content column shifted to clear it. */
70
+ /** The live hour: tinted chip and hairline. The live label is screen-reader only in this variant. */
70
71
  export const Live: Story = {
71
72
  args: {
72
73
  time: "20:00",
@@ -84,6 +85,28 @@ export const Live: Story = {
84
85
  },
85
86
  };
86
87
 
88
+ /** The shipped mobile shape: compact rows, ruled between, inside one band. */
89
+ export const CompactRows: Story = {
90
+ render: () => (
91
+ <HourBand time="20:00" isLive liveLabel="Now on">
92
+ <EventCompactRow
93
+ event={event("r1", "Klausen Quiz by GOtrivia", "Klausen Pubhouse", 20)}
94
+ labels={labels}
95
+ formatters={formatters}
96
+ hideTime
97
+ tags={["Drinks", "Games"]}
98
+ />
99
+ <EventCompactRow
100
+ event={event("r2", "Quiz Night at Café del Mars", "Del Mars", 10)}
101
+ labels={labels}
102
+ formatters={formatters}
103
+ hideTime
104
+ tags={["Games"]}
105
+ />
106
+ </HourBand>
107
+ ),
108
+ };
109
+
87
110
  /** A single event in a quiet hour. */
88
111
  export const OneEvent: Story = {
89
112
  args: {
@@ -133,9 +156,9 @@ export const StackedHours: Story = {
133
156
  };
134
157
 
135
158
  /**
136
- * Loading. The puck is omitted rather than blocked outthe 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".
159
+ * Loading. The chip is blocked out at its fixed 58x24 size a grey lozenge
160
+ * of that exact shape reads as "a time goes here" without inventing one and
161
+ * the rows mirror `EventCompactRowSkeleton`.
139
162
  */
140
163
  export const Loading: Story = {
141
164
  render: () => <HourBandSkeleton />,