@bearmenu/ui 0.8.17 → 0.8.19

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 (59) hide show
  1. package/dist/{chunk-WYTJR2YU.js → chunk-5CPBSNWQ.js} +6 -1
  2. package/dist/chunk-BRXI6EDO.js +84 -0
  3. package/dist/{chunk-54FQQZZJ.js → chunk-EQYB4Q77.js} +1 -1
  4. package/dist/chunk-QRSIJC4X.js +156 -0
  5. package/dist/components/concept-compare-rail.js +2 -2
  6. package/dist/components/concept-compare.js +2 -2
  7. package/dist/components/event-card.d.ts +22 -4
  8. package/dist/components/event-card.js +95 -50
  9. package/dist/components/event-day-header.d.ts +5 -3
  10. package/dist/components/event-day-header.js +12 -4
  11. package/dist/components/event-hub-hero.d.ts +12 -2
  12. package/dist/components/event-hub-hero.js +11 -5
  13. package/dist/components/event-tonight-strip.d.ts +50 -0
  14. package/dist/components/event-tonight-strip.js +67 -0
  15. package/dist/components/menu-family-block.js +4 -3
  16. package/dist/components/menu-hub-hero.d.ts +7 -1
  17. package/dist/components/menu-hub-hero.js +2 -1
  18. package/dist/components/poster-stack.d.ts +36 -3
  19. package/dist/components/poster-stack.js +87 -48
  20. package/dist/components/snap-rail.d.ts +2 -2
  21. package/dist/components/snap-rail.js +1 -1
  22. package/dist/components/timeline-day.d.ts +54 -0
  23. package/dist/components/timeline-day.js +65 -0
  24. package/dist/components/timeline-hour-puck.d.ts +47 -0
  25. package/dist/components/timeline-hour-puck.js +3 -0
  26. package/dist/components/timeline-hour.d.ts +96 -0
  27. package/dist/components/{timeline-band.js → timeline-hour.js} +2 -1
  28. package/dist/components/timeline-playhead.js +1 -3
  29. package/package.json +1 -1
  30. package/src/components/event-card.tsx +70 -13
  31. package/src/components/event-day-header.tsx +19 -7
  32. package/src/components/event-hub-hero.tsx +25 -3
  33. package/src/components/event-tonight-strip.stories.tsx +78 -0
  34. package/src/components/event-tonight-strip.test.tsx +49 -0
  35. package/src/components/event-tonight-strip.tsx +134 -0
  36. package/src/components/menu-family-block.tsx +13 -6
  37. package/src/components/menu-hub-hero.test.tsx +13 -0
  38. package/src/components/menu-hub-hero.tsx +9 -2
  39. package/src/components/poster-stack.stories.tsx +43 -1
  40. package/src/components/poster-stack.tsx +104 -19
  41. package/src/components/snap-rail.tsx +6 -1
  42. package/src/components/timeline-day.stories.tsx +201 -0
  43. package/src/components/timeline-day.test.tsx +78 -0
  44. package/src/components/timeline-day.tsx +114 -0
  45. package/src/components/timeline-hour-puck.tsx +142 -0
  46. package/src/components/timeline-hour.tsx +266 -0
  47. package/src/components/timeline-playhead.tsx +1 -1
  48. package/dist/chunk-NSNEANOQ.js +0 -104
  49. package/dist/components/hour-band.d.ts +0 -66
  50. package/dist/components/hour-band.js +0 -81
  51. package/dist/components/hour-group.d.ts +0 -67
  52. package/dist/components/hour-group.js +0 -59
  53. package/dist/components/timeline-band.d.ts +0 -64
  54. package/src/components/hour-band.stories.tsx +0 -182
  55. package/src/components/hour-band.tsx +0 -146
  56. package/src/components/hour-group.stories.tsx +0 -100
  57. package/src/components/hour-group.tsx +0 -124
  58. package/src/components/timeline-band.stories.tsx +0 -206
  59. package/src/components/timeline-band.tsx +0 -247
@@ -0,0 +1,47 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+
3
+ /**
4
+ * The phone axis gutter, shared by `TimelineHour` and `TimelinePlayhead` so
5
+ * the hour chips and the current-time label sit in one column. 58px is the
6
+ * width of a five-glyph 24-hour label ("20:00") in the chip's mono type plus
7
+ * its padding; a 12-hour locale's "09:00 AM" overflows it, which is one of the
8
+ * reasons the events surfaces format 24-hour everywhere.
9
+ */
10
+ declare const TIMELINE_MOBILE_GUTTER_CLASS = "w-[58px]";
11
+ type TimelineHourPuckProps = {
12
+ /** The hour, already formatted and localised — or an em dash for a quiet day. */
13
+ time: string;
14
+ /** Marks this as the hour currently in progress. */
15
+ isLive?: boolean;
16
+ /** Shown under the puck when live, e.g. "NOW ON". Required to render it. */
17
+ liveLabel?: string;
18
+ /**
19
+ * `station`: the bordered puck that sits ON the hairline, for the desktop
20
+ * spine. `chip`: a filled, fixed-width lozenge with the hairline starting
21
+ * BELOW it, for the phone gutter — an app's time label, not a station on a
22
+ * rail. `responsive` (the default): the chip below `lg`, the station from
23
+ * `lg` — ONE node that changes its look with the viewport, so a
24
+ * server-rendered timeline carries a single DOM for both widths. In the
25
+ * chip look the live label is visually hidden: the tinted chip plus a small
26
+ * on-air glyph are the visible signal (the glyph so the state never rests on
27
+ * colour alone once the pulse is reduced away) and the label stays for
28
+ * screen readers only.
29
+ */
30
+ variant?: "station" | "chip" | "responsive";
31
+ className?: string;
32
+ };
33
+ /**
34
+ * The station on the timeline's axis — ONE object, positioned by its host
35
+ * (`TimelineHour`): pinned to the top of the phone gutter, centred on the
36
+ * hour's heading rule out on the desktop spine.
37
+ *
38
+ * The `bg-background` fill punches through the hairline so the rail reads as
39
+ * continuous behind the label rather than butting into it.
40
+ *
41
+ * The live label is absolutely positioned rather than a flex sibling: hosts
42
+ * centre this puck with `-translate-y-1/2`, and a two-item column would centre
43
+ * the STACK, lifting the puck off the line it exists to sit on.
44
+ */
45
+ declare function TimelineHourPuck({ time, isLive, liveLabel, variant, className, }: TimelineHourPuckProps): react_jsx_runtime.JSX.Element;
46
+
47
+ export { TIMELINE_MOBILE_GUTTER_CLASS, TimelineHourPuck, type TimelineHourPuckProps };
@@ -0,0 +1,3 @@
1
+ export { TIMELINE_MOBILE_GUTTER_CLASS, TimelineHourPuck } from '../chunk-BRXI6EDO.js';
2
+ import '../chunk-FEK5XGZW.js';
3
+ import '../chunk-MMUBH76A.js';
@@ -0,0 +1,96 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import * as React from 'react';
3
+
4
+ /**
5
+ * TimelineHour — one hour of a day's programme, ONE DOM for both viewports.
6
+ *
7
+ * Replaces `HourBand` (the phone band: a 58px gutter with a filled chip and
8
+ * a hairline dropping from it, the hour's cards beside) and `HourGroup` (the
9
+ * desktop heading: a station out on `TimelineDay`'s spine, a rule and a
10
+ * count). They were two components because the timeline picked one per
11
+ * viewport in JavaScript, and a server render cannot know the viewport — so
12
+ * the server rendered nothing. This is both, with CSS deciding:
13
+ *
14
+ * below `lg` the section carries a 70px left inset; the puck sits at the
15
+ * top-left of that gutter as a chip, a hairline drops beneath
16
+ * it, and the heading row is hidden — the chip IS the heading.
17
+ * from `lg` the inset goes, the heading row shows (rule + count), and the
18
+ * puck moves out to the spine, centred on that rule, as a
19
+ * station. Its vertical position is a constant (`--timeline-
20
+ * heading` below), which is why the heading row has a fixed
21
+ * height: "top-1/2 of the row" needs the puck INSIDE the row,
22
+ * and inside the row it cannot also be the phone's gutter chip.
23
+ *
24
+ * The invariant `TimelineBand` used to state still holds: AN HOUR APPEARS
25
+ * EXACTLY ONCE, AND ALWAYS ON THE SPINE. Roles are how a day keeps it while
26
+ * one of its events is lifted into the desktop lane:
27
+ *
28
+ * hour the default — a station, the hour's events.
29
+ * hero the day's feature. Below `lg` a normal hour holding one
30
+ * card, in its chronological slot. From `lg` the node moves
31
+ * to `TimelineDay`'s lane column (grid placement, not source
32
+ * order), pins, and loses its puck and heading — the lane
33
+ * card carries its own start time, see `EventCard` `hideWhen`
34
+ * `"below-lg"`.
35
+ * continuation what is left of the hero's hour. Below `lg` its chip is
36
+ * `invisible` (the gutter and hairline stay, so the reader sees
37
+ * ONE band for that hour, hero first); from `lg` it shows its
38
+ * station, because the hero is in the lane now and the hour
39
+ * needs its puck back. When the hero was its hour's only
40
+ * event there is no continuation, and the hour simply leaves
41
+ * the desktop column — exactly what `HourGroup`'s
42
+ * `excludeEventId` used to do.
43
+ * unscheduled events the ingest dated but never timed. No station at any
44
+ * width — the axis encodes clock position and these have none
45
+ * — and no gutter inset: an inline heading (the caller's
46
+ * label, a rule, the count) at both widths, after the day's
47
+ * real programme.
48
+ *
49
+ * `layout`: `cards` stacks posters with a gap (a card is its own frame, a
50
+ * hairline between two posters reads as a third object) and from `lg` lays
51
+ * them on a two/three-up grid; `rows` rules between children on both widths.
52
+ * Children are read with `Children.toArray`, which drops `null`/`false`, so a
53
+ * conditional first row never inherits a rule meant for a second; pass rows
54
+ * as siblings, not inside a fragment.
55
+ */
56
+ type TimelineHourRole = "hour" | "hero" | "continuation" | "unscheduled";
57
+ type TimelineHourProps = {
58
+ /** The hour, already formatted and localised — "20:00". For `unscheduled`,
59
+ * the caller's label for the group ("Time to be confirmed"). */
60
+ time: string;
61
+ role?: TimelineHourRole;
62
+ /** Marks this as the hour currently in progress. */
63
+ isLive?: boolean;
64
+ /** Under the station when live, e.g. "NOW ON"; screen-reader-only in the chip. */
65
+ liveLabel?: string;
66
+ /** Desktop heading count, e.g. "3 events" — mirrors `EventDayHeader`'s `meta`.
67
+ * Also the phone heading for `unscheduled`. */
68
+ countLabel?: string;
69
+ /** Trailing note after the children, e.g. "No start time was published". */
70
+ note?: string;
71
+ layout?: "cards" | "rows";
72
+ /** The hour's events — each direct child one card or row. */
73
+ children: React.ReactNode;
74
+ /**
75
+ * Events past the hour's card cap, after `children`: cards on the phone
76
+ * (the same stack, same gap), rows from `lg` (ruled, under the grid) —
77
+ * pass `EventCard density="row-lg"`. The desktop's density ladder inside
78
+ * one hour: a few posters, then rows.
79
+ */
80
+ overflow?: React.ReactNode;
81
+ className?: string;
82
+ };
83
+ declare function TimelineHour({ time, role, isLive, liveLabel, countLabel, note, layout, children, overflow, className, }: TimelineHourProps): react_jsx_runtime.JSX.Element;
84
+ type TimelineHourSkeletonProps = {
85
+ /** Cards to reserve space for. */
86
+ cardCount?: number;
87
+ className?: string;
88
+ };
89
+ /**
90
+ * Mirrors `TimelineHour` at both widths — the phone's gutter chip and
91
+ * hairline, the desktop's grid of poster placeholders — so nothing jumps when
92
+ * data lands.
93
+ */
94
+ declare function TimelineHourSkeleton({ cardCount, className }: TimelineHourSkeletonProps): react_jsx_runtime.JSX.Element;
95
+
96
+ export { TimelineHour, type TimelineHourProps, type TimelineHourRole, TimelineHourSkeleton, type TimelineHourSkeletonProps };
@@ -1,4 +1,5 @@
1
- export { TIMELINE_MOBILE_GUTTER_CLASS, TimelineBand, TimelineBandSkeleton, TimelineHourPuck } from '../chunk-NSNEANOQ.js';
1
+ export { TimelineHour, TimelineHourSkeleton } from '../chunk-QRSIJC4X.js';
2
+ import '../chunk-BRXI6EDO.js';
2
3
  import '../chunk-NWZGSLPU.js';
3
4
  import '../chunk-XBBEX4SF.js';
4
5
  import '../chunk-FEK5XGZW.js';
@@ -1,6 +1,4 @@
1
- import { TIMELINE_MOBILE_GUTTER_CLASS } from '../chunk-NSNEANOQ.js';
2
- import '../chunk-NWZGSLPU.js';
3
- import '../chunk-XBBEX4SF.js';
1
+ import { TIMELINE_MOBILE_GUTTER_CLASS } from '../chunk-BRXI6EDO.js';
4
2
  import { cn } from '../chunk-FEK5XGZW.js';
5
3
  import '../chunk-MMUBH76A.js';
6
4
  import { jsxs, jsx } from 'react/jsx-runtime';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bearmenu/ui",
3
- "version": "0.8.17",
3
+ "version": "0.8.19",
4
4
  "description": "BearMenu design system — shared UI tokens, primitives, and components",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -55,8 +55,22 @@ export type EventCardProps = {
55
55
  * `Coverflow`) — it crops less than `3/4` and is not the same as it.
56
56
  */
57
57
  posterAspect?: "4/3" | "3/4" | "4/5";
58
- /** Larger title — the featured/highlight slot. Never a clamp: the title wraps in full either way. */
59
- emphasis?: boolean;
58
+ /**
59
+ * Larger title — the featured/highlight slot. Never a clamp: the title
60
+ * wraps in full either way. `"lg"` applies it from the `lg` breakpoint
61
+ * only: the timeline's day hero is an ordinary card in the phone column and
62
+ * the lane's feature poster on desktop, one node.
63
+ */
64
+ emphasis?: boolean | "lg";
65
+ /**
66
+ * `card` (default) at every width. `row-lg`: the card below `lg`, and from
67
+ * `lg` the compact row `EventCompactRow` draws — a 64px portrait poster
68
+ * beside the text, overlay pills off, the fee as a chip on the tags row.
69
+ * The timeline demotes an hour's later events to rows on desktop only, and
70
+ * a server render cannot swap components per viewport; this is the one
71
+ * node that paints both.
72
+ */
73
+ density?: "card" | "row-lg";
60
74
  /** Top-left overlay pill (the design's "Busy" hype marker). Include its icon. */
61
75
  badge?: React.ReactNode;
62
76
  /** Bottom-left overlay pill naming the category. Include its icon. */
@@ -77,8 +91,12 @@ export type EventCardProps = {
77
91
  * locale, and "is this live" depends on the app's clock, not the DS's.
78
92
  */
79
93
  liveStatus?: string;
80
- /** Hide the day/time line — the timeline supplies its own hour heading. */
81
- hideWhen?: boolean;
94
+ /**
95
+ * Hide the day/time line — the timeline supplies its own hour heading.
96
+ * `"below-lg"` hides it on the phone only: the desktop lane's hero sits
97
+ * beside whatever hour is passing and has to carry its own start time.
98
+ */
99
+ hideWhen?: boolean | "below-lg";
82
100
  /**
83
101
  * Already over — dims the poster, mutes the title and shows the `endedLabel`
84
102
  * chip. Never row-level opacity, which composites the text below AA. Same
@@ -111,6 +129,7 @@ export const EventCard = memo(function EventCard({
111
129
  formatters,
112
130
  posterAspect = "4/3",
113
131
  emphasis,
132
+ density = "card",
114
133
  badge,
115
134
  tagLabel,
116
135
  tags,
@@ -129,7 +148,10 @@ export const EventCard = memo(function EventCard({
129
148
  }: EventCardProps) {
130
149
  const [imgError, setImgError] = useState(false);
131
150
  const showEnded = isEnded && !!endedLabel;
132
- const time = hideWhen ? "" : formatters.time(event.starts_at);
151
+ // `"below-lg"` still renders the line (hidden by CSS on the phone), so the
152
+ // time is formatted whenever the line can show anywhere.
153
+ const time = hideWhen === true ? "" : formatters.time(event.starts_at);
154
+ const isRowLg = density === "row-lg";
133
155
 
134
156
  const fee = resolveEventEntryFee(event);
135
157
  const showPoster = !!event.poster_url && !imgError;
@@ -146,6 +168,8 @@ export const EventCard = memo(function EventCard({
146
168
  : posterAspect === "4/5"
147
169
  ? "aspect-[4/5]"
148
170
  : "aspect-[4/3]",
171
+ // The compact row's poster from `lg`: 64px, portrait, tighter radius.
172
+ isRowLg && "lg:aspect-[4/5] lg:w-16 lg:shrink-0 lg:rounded-[10px]",
149
173
  )}
150
174
  >
151
175
  {showPoster ? (
@@ -164,8 +188,14 @@ export const EventCard = memo(function EventCard({
164
188
  )}
165
189
 
166
190
  {/* Top overlay row — hype badge left, entry fee right. The empty span
167
- keeps the fee pill right-aligned when there is no badge. */}
168
- <div className="absolute inset-x-2 top-2 flex items-start justify-between gap-2">
191
+ keeps the fee pill right-aligned when there is no badge. Off on a
192
+ 64px row poster, where the fee moves to the tags row. */}
193
+ <div
194
+ className={cn(
195
+ "absolute inset-x-2 top-2 flex items-start justify-between gap-2",
196
+ isRowLg && "lg:hidden",
197
+ )}
198
+ >
169
199
  {badge ? (
170
200
  <span className={cn(PILL_BASE, "bg-black/55 text-white")}>
171
201
  {badge}
@@ -187,7 +217,12 @@ export const EventCard = memo(function EventCard({
187
217
  </div>
188
218
 
189
219
  {tagLabel && (
190
- <div className="absolute inset-x-2 bottom-2 flex gap-1">
220
+ <div
221
+ className={cn(
222
+ "absolute inset-x-2 bottom-2 flex gap-1",
223
+ isRowLg && "lg:hidden",
224
+ )}
225
+ >
191
226
  <span className={cn(PILL_BASE, "bg-black/55 text-white")}>
192
227
  {tagLabel}
193
228
  </span>
@@ -195,6 +230,8 @@ export const EventCard = memo(function EventCard({
195
230
  )}
196
231
  </div>
197
232
 
233
+ {/* The text column — a wrapper so `row-lg` can lay it beside the poster. */}
234
+ <div className={cn("flex min-w-0 flex-col", isRowLg && "lg:flex-1")}>
198
235
  {/* NEVER CLAMPED, in any variant. The title is the one thing a card
199
236
  exists to say, and "Movie Night: Futni m…" says none of it — the
200
237
  truncated form was the reported defect. It wraps to as many lines as
@@ -205,9 +242,11 @@ export const EventCard = memo(function EventCard({
205
242
  <h3
206
243
  className={cn(
207
244
  "mt-2.5 leading-[1.35]",
208
- emphasis
245
+ emphasis === true
209
246
  ? "text-lg font-bold tracking-[-0.02em]"
210
247
  : "text-sm font-semibold",
248
+ emphasis === "lg" && "lg:text-lg lg:font-bold lg:tracking-[-0.02em]",
249
+ isRowLg && "lg:mt-0 lg:text-[13.5px]",
211
250
  isEnded && "text-muted-foreground",
212
251
  )}
213
252
  >
@@ -219,8 +258,13 @@ export const EventCard = memo(function EventCard({
219
258
  <span className="truncate">{venue}</span>
220
259
  </div>
221
260
 
222
- {!hideWhen && (
223
- <div className="mt-[3px] flex items-center gap-1.5 text-xs text-muted-foreground">
261
+ {hideWhen !== true && (
262
+ <div
263
+ className={cn(
264
+ "mt-[3px] flex items-center gap-1.5 text-xs text-muted-foreground",
265
+ hideWhen === "below-lg" && "hidden lg:flex",
266
+ )}
267
+ >
224
268
  <span>{dayLabel ?? formatters.day(event.starts_at)}</span>
225
269
  {/* An empty time is "hour unknown" (see `EventFormatters.time`): the
226
270
  day stands alone, no dangling separator. Consumers used to have
@@ -244,13 +288,21 @@ export const EventCard = memo(function EventCard({
244
288
  </div>
245
289
  )}
246
290
 
247
- {tags && tags.length > 0 && (
291
+ {/* The row look carries the fee as a chip here, where the compact row
292
+ puts it — the poster is too small for an overlay pill. */}
293
+ {((tags && tags.length > 0) || (isRowLg && fee.kind === "free")) && (
248
294
  <TagGroup className="mt-1.5">
249
- {tags.map((tag) => (
295
+ {tags?.map((tag) => (
250
296
  <Tag key={tag} size="sm">
251
297
  {tag}
252
298
  </Tag>
253
299
  ))}
300
+ {isRowLg && fee.kind === "free" && (
301
+ <Tag size="sm" variant="success" className="hidden font-bold lg:inline-flex">
302
+ <Gift className="h-3 w-3" aria-hidden />
303
+ {labels.free}
304
+ </Tag>
305
+ )}
254
306
  </TagGroup>
255
307
  )}
256
308
 
@@ -268,13 +320,18 @@ export const EventCard = memo(function EventCard({
268
320
  </Tag>
269
321
  </div>
270
322
  )}
323
+ </div>
271
324
  </>
272
325
  );
273
326
 
274
327
  const rootClasses = cn(
275
328
  "flex min-w-0 flex-col text-left",
329
+ // The compact row from `lg`: poster beside text, the row's own padding
330
+ // and negative gutter so the hover wash bleeds like `EventCompactRow`'s.
331
+ isRowLg && "lg:-mx-2.5 lg:flex-row lg:items-start lg:gap-[11px] lg:rounded-lg lg:px-2.5 lg:py-2",
276
332
  (href || onClick) &&
277
333
  "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl",
334
+ (href || onClick) && isRowLg && "lg:hover:bg-foreground/5",
278
335
  className,
279
336
  );
280
337
 
@@ -47,10 +47,12 @@ export type EventDayHeaderProps = {
47
47
  */
48
48
  action?: React.ReactNode;
49
49
  /**
50
- * Larger desktop scale: 58px day number, 18px weekday, 12.5px meta,
51
- * against the mobile 40/15/11.5.
50
+ * `sm`: the phone scale (30px day number, 15px weekday, 11.5px meta).
51
+ * `default`: the desktop scale (58 / 18 / 12.5). `responsive`: `sm` below
52
+ * `lg` and `default` from it, in one node — for a server render that
53
+ * cannot know the viewport.
52
54
  */
53
- size?: "sm" | "default";
55
+ size?: "sm" | "default" | "responsive";
54
56
  href?: string;
55
57
  linkComponent?: React.ComponentType<EventDayHeaderLinkProps>;
56
58
  onClick?: () => void;
@@ -70,6 +72,9 @@ export function EventDayHeader({
70
72
  }: EventDayHeaderProps) {
71
73
  const isInteractive = !!href || !!onClick;
72
74
  const large = size === "default";
75
+ // `responsive` is the phone scale with every `default` class as an `lg:`
76
+ // twin — one node for a server-rendered timeline.
77
+ const responsive = size === "responsive";
73
78
 
74
79
  const body = (
75
80
  <>
@@ -78,21 +83,27 @@ export function EventDayHeader({
78
83
  "font-black leading-[0.82] tracking-[-0.05em] tabular-nums",
79
84
  // `sm` is the phone scale: 30px, the design's app day head. 40px
80
85
  // was a page-scale number that took a third of a 390px row.
81
- large ? "text-[58px]" : "text-[30px] leading-[0.86]"
86
+ large ? "text-[58px]" : "text-[30px] leading-[0.86]",
87
+ responsive && "lg:text-[58px] lg:leading-[0.82]"
82
88
  )}
83
89
  >
84
90
  {dayNumber}
85
91
  </div>
86
- <div className={large ? "pb-1" : "pb-0.5"}>
92
+ <div className={cn(large ? "pb-1" : "pb-0.5", responsive && "lg:pb-1")}>
87
93
  <div
88
- className={cn("font-extrabold tracking-[-0.015em]", large ? "text-lg" : "text-[15px]")}
94
+ className={cn(
95
+ "font-extrabold tracking-[-0.015em]",
96
+ large ? "text-lg" : "text-[15px]",
97
+ responsive && "lg:text-lg"
98
+ )}
89
99
  >
90
100
  {weekday}
91
101
  </div>
92
102
  <div
93
103
  className={cn(
94
104
  "mt-0.5 text-muted-foreground",
95
- large ? "whitespace-nowrap text-[12.5px]" : "text-[11.5px]"
105
+ large ? "whitespace-nowrap text-[12.5px]" : "text-[11.5px]",
106
+ responsive && "lg:whitespace-nowrap lg:text-[12.5px]"
96
107
  )}
97
108
  >
98
109
  {meta}
@@ -132,6 +143,7 @@ export function EventDayHeader({
132
143
  className={cn(
133
144
  "relative flex w-full items-end gap-3 pb-3 pt-[18px]",
134
145
  large && "gap-[18px] pb-4",
146
+ responsive && "lg:gap-[18px] lg:pb-4",
135
147
  className
136
148
  )}
137
149
  >
@@ -47,6 +47,14 @@ export type EventHubHeroProps = {
47
47
  statsLabel?: string;
48
48
  /** The right-column slot — the poster stack in the source design. */
49
49
  aside?: React.ReactNode;
50
+ /**
51
+ * The hub's tonight state strip. The figures say scale; this says what is
52
+ * on right now, and it is the one thing on the first screen a reader can
53
+ * tap into the timeline. On a phone it follows the aside — title, figures,
54
+ * posters, then the one line that says what is on — and from `lg` it is a
55
+ * content-width row under both columns.
56
+ */
57
+ footer?: React.ReactNode;
50
58
  className?: string;
51
59
  };
52
60
 
@@ -57,12 +65,16 @@ export function EventHubHero({
57
65
  stats,
58
66
  statsLabel,
59
67
  aside,
68
+ footer,
60
69
  className,
61
70
  }: EventHubHeroProps) {
62
71
  return (
63
72
  <div
64
73
  className={cn(
65
- "grid grid-cols-1 gap-8 lg:grid-cols-[1fr_620px] lg:items-center lg:gap-10",
74
+ // 12px between the phone's blocks — the page's rhythm, the same as
75
+ // between the sections below: with a tappable poster stack the aside
76
+ // is content, not decoration, and gets no section gap of its own.
77
+ "grid grid-cols-1 gap-3 lg:grid-cols-[1fr_620px] lg:items-center lg:gap-x-10 lg:gap-y-6",
66
78
  className
67
79
  )}
68
80
  >
@@ -134,6 +146,8 @@ export function EventHubHero({
134
146
  </div>
135
147
 
136
148
  {aside && <div className="min-w-0">{aside}</div>}
149
+
150
+ {footer && <div className="lg:col-span-2 lg:w-fit lg:min-w-[480px]">{footer}</div>}
137
151
  </div>
138
152
  );
139
153
  }
@@ -141,15 +155,17 @@ export function EventHubHero({
141
155
  export type EventHubHeroSkeletonProps = {
142
156
  /** The right-column slot, e.g. a `PosterStack`-shaped placeholder. Mirrors `aside` on the real component. */
143
157
  aside?: React.ReactNode;
158
+ /** Reserves the `footer` row — the tonight strip's 52px. */
159
+ footer?: boolean;
144
160
  className?: string;
145
161
  };
146
162
 
147
- export function EventHubHeroSkeleton({ aside, className }: EventHubHeroSkeletonProps) {
163
+ export function EventHubHeroSkeleton({ aside, footer, className }: EventHubHeroSkeletonProps) {
148
164
  return (
149
165
  <div
150
166
  aria-hidden="true"
151
167
  className={cn(
152
- "grid grid-cols-1 gap-8 lg:grid-cols-[1fr_620px] lg:items-center lg:gap-10",
168
+ "grid grid-cols-1 gap-3 lg:grid-cols-[1fr_620px] lg:items-center lg:gap-x-10 lg:gap-y-6",
153
169
  className
154
170
  )}
155
171
  >
@@ -177,6 +193,12 @@ export function EventHubHeroSkeleton({ aside, className }: EventHubHeroSkeletonP
177
193
  </div>
178
194
 
179
195
  {aside && <div className="min-w-0">{aside}</div>}
196
+
197
+ {footer && (
198
+ <div className="lg:col-span-2 lg:w-fit lg:min-w-[480px]">
199
+ <Skeleton className="h-[52px] w-full rounded-[14px]" />
200
+ </div>
201
+ )}
180
202
  </div>
181
203
  );
182
204
  }
@@ -0,0 +1,78 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { EventTonightStrip, EventTonightStripSkeleton } from "./event-tonight-strip";
3
+
4
+ const meta: Meta<typeof EventTonightStrip> = {
5
+ title: "Events/EventTonightStrip",
6
+ component: EventTonightStrip,
7
+ tags: ["autodocs"],
8
+ decorators: [
9
+ (Story) => (
10
+ <div className="w-[343px] bg-surface-2 p-4 md:w-[520px]">
11
+ <Story />
12
+ </div>
13
+ ),
14
+ ],
15
+ parameters: {
16
+ docs: {
17
+ description: {
18
+ component:
19
+ "The hub's state line under the hero: what is on right now, what is later tonight, and one tap into the timeline. Copy is caller-composed and carries only absolute facts (counts, wall-clock times) so an ISR snapshot can never say something false. Tone `none` renders a non-interactive status line.",
20
+ },
21
+ },
22
+ },
23
+ };
24
+
25
+ export default meta;
26
+ type Story = StoryObj<typeof EventTonightStrip>;
27
+
28
+ export const Live: Story = {
29
+ args: {
30
+ tone: "live",
31
+ lead: "3 live now",
32
+ details: ["12 more tonight", "next at 20:00"],
33
+ actionLabel: "Open tonight in the schedule",
34
+ href: "#events-day-2026-09-17",
35
+ },
36
+ };
37
+
38
+ export const Tonight: Story = {
39
+ args: {
40
+ tone: "tonight",
41
+ lead: "9 tonight",
42
+ details: ["first at 19:00"],
43
+ actionLabel: "Open tonight in the schedule",
44
+ href: "#events-day-2026-09-17",
45
+ },
46
+ };
47
+
48
+ export const Quiet: Story = {
49
+ args: {
50
+ tone: "quiet",
51
+ lead: "Nothing on tonight",
52
+ details: ["next Fri 20:00", "7 events"],
53
+ actionLabel: "Open Friday in the schedule",
54
+ href: "#events-day-2026-09-19",
55
+ },
56
+ };
57
+
58
+ export const Over: Story = {
59
+ args: {
60
+ tone: "quiet",
61
+ lead: "Tonight's events are over",
62
+ details: ["next tomorrow 18:00", "4 events"],
63
+ actionLabel: "Open tomorrow in the schedule",
64
+ href: "#events-day-2026-09-18",
65
+ },
66
+ };
67
+
68
+ export const None: Story = {
69
+ args: {
70
+ tone: "none",
71
+ lead: "Nothing scheduled in the next 14 days",
72
+ details: ["Check back soon"],
73
+ },
74
+ };
75
+
76
+ export const Skeleton: Story = {
77
+ render: () => <EventTonightStripSkeleton />,
78
+ };
@@ -0,0 +1,49 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { EventTonightStrip } from "./event-tonight-strip";
4
+ import { PosterStack } from "./poster-stack";
5
+
6
+ describe("EventTonightStrip", () => {
7
+ it("is an anchor with the lead, the details and the action as its name", () => {
8
+ render(
9
+ <EventTonightStrip
10
+ tone="live"
11
+ lead="3 live now"
12
+ details={["12 more tonight", "next at 20:00"]}
13
+ actionLabel="Open tonight in the schedule"
14
+ href="/events/schedule#events-day-2026-09-17"
15
+ />
16
+ );
17
+ const link = screen.getByRole("link");
18
+ expect(link).toHaveAttribute("href", "/events/schedule#events-day-2026-09-17");
19
+ expect(link).toHaveAccessibleName(
20
+ "3 live now 12 more tonight next at 20:00. Open tonight in the schedule"
21
+ );
22
+ });
23
+
24
+ it("renders a status line, not a link, when there is nothing to open", () => {
25
+ render(<EventTonightStrip tone="none" lead="Nothing scheduled" />);
26
+ expect(screen.queryByRole("link")).toBeNull();
27
+ expect(screen.getByRole("status")).toHaveTextContent("Nothing scheduled");
28
+ });
29
+ });
30
+
31
+ describe("PosterStack", () => {
32
+ it("makes a captioned poster an anchor named by its caption", () => {
33
+ // The float's reduced-motion check reads matchMedia, which jsdom lacks.
34
+ window.matchMedia = () => ({ matches: true }) as MediaQueryList;
35
+ render(
36
+ <PosterStack
37
+ posters={[
38
+ { src: "/a.jpg", alt: "A poster", caption: "Subcarpați live", href: "/events/e/a" },
39
+ { src: "/b.jpg", alt: "B poster" },
40
+ ]}
41
+ />
42
+ );
43
+ const link = screen.getByRole("link", { name: "Subcarpați live" });
44
+ expect(link).toHaveAttribute("href", "/events/e/a");
45
+ // The uncaptioned poster stays decorative: no anchor, its own alt.
46
+ expect(screen.getAllByRole("link")).toHaveLength(1);
47
+ expect(screen.getByAltText("B poster")).toBeInTheDocument();
48
+ });
49
+ });