@bearmenu/ui 0.8.50 → 0.8.52

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 (65) hide show
  1. package/dist/chunk-H4ORZT2D.js +14 -0
  2. package/dist/{chunk-S5GDARLW.js → chunk-WY2FP76J.js} +28 -5
  3. package/dist/components/alert.d.ts +2 -2
  4. package/dist/components/avatar.d.ts +2 -2
  5. package/dist/components/badge.d.ts +1 -1
  6. package/dist/components/banner.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.d.ts +6 -1
  10. package/dist/components/category-deck.js +39 -35
  11. package/dist/components/empty-state.d.ts +2 -2
  12. package/dist/components/event-card.d.ts +7 -0
  13. package/dist/components/event-card.js +9 -0
  14. package/dist/components/event-compact-row.d.ts +6 -0
  15. package/dist/components/event-compact-row.js +5 -3
  16. package/dist/components/event-hub-hero.d.ts +5 -0
  17. package/dist/components/event-hub-hero.js +14 -3
  18. package/dist/components/event-list-row-wide.d.ts +6 -0
  19. package/dist/components/event-list-row-wide.js +5 -3
  20. package/dist/components/event-tonight-strip.d.ts +3 -1
  21. package/dist/components/event-tonight-strip.js +3 -2
  22. package/dist/components/featured-rail-item.d.ts +3 -1
  23. package/dist/components/featured-rail-item.js +3 -1
  24. package/dist/components/item.d.ts +2 -2
  25. package/dist/components/kitchen-dossier.d.ts +7 -1
  26. package/dist/components/kitchen-dossier.js +2 -1
  27. package/dist/components/menu-hub-hero.d.ts +7 -1
  28. package/dist/components/menu-hub-hero.js +2 -0
  29. package/dist/components/place-card.d.ts +7 -0
  30. package/dist/components/place-card.js +10 -2
  31. package/dist/components/poster-stack.d.ts +2 -0
  32. package/dist/components/poster-stack.js +2 -1
  33. package/dist/components/section-header.d.ts +6 -1
  34. package/dist/components/section-header.js +4 -2
  35. package/dist/components/sliding-panels.d.ts +6 -0
  36. package/dist/components/sliding-panels.js +13 -3
  37. package/dist/components/spinner.d.ts +2 -2
  38. package/dist/components/tag.d.ts +2 -2
  39. package/dist/components/text.d.ts +3 -3
  40. package/dist/components/timeline-day.js +1 -1
  41. package/dist/components/timeline-hour.d.ts +13 -1
  42. package/dist/components/timeline-hour.js +1 -1
  43. package/package.json +1 -1
  44. package/src/components/category-deck.tsx +11 -0
  45. package/src/components/event-card.test.tsx +6 -0
  46. package/src/components/event-card.tsx +17 -0
  47. package/src/components/event-compact-row.tsx +34 -24
  48. package/src/components/event-hub-hero.tsx +15 -1
  49. package/src/components/event-list-row-wide.tsx +25 -15
  50. package/src/components/event-tonight-strip.test.tsx +12 -0
  51. package/src/components/event-tonight-strip.tsx +8 -1
  52. package/src/components/featured-rail-item.tsx +5 -0
  53. package/src/components/kitchen-dossier.tsx +14 -1
  54. package/src/components/menu-hub-hero.tsx +13 -0
  55. package/src/components/place-card.stories.tsx +10 -0
  56. package/src/components/place-card.test.tsx +31 -0
  57. package/src/components/place-card.tsx +22 -6
  58. package/src/components/poster-stack.tsx +3 -0
  59. package/src/components/section-header.tsx +8 -1
  60. package/src/components/sliding-panels.test.tsx +26 -4
  61. package/src/components/sliding-panels.tsx +15 -4
  62. package/src/components/timeline-day.stories.tsx +30 -0
  63. package/src/components/timeline-day.test.tsx +24 -0
  64. package/src/components/timeline-hour.tsx +40 -6
  65. package/src/lib/poster-overlay-frame.tsx +26 -0
@@ -96,6 +96,12 @@ export type KitchenDossierProps = {
96
96
  partDurationMs?: number;
97
97
  /** The foot's action — pass a rendered link or button. */
98
98
  action?: ReactNode;
99
+ /**
100
+ * Drawn in place of the open-until pill's dot, in the dot's slot (roughly
101
+ * 16-22px square). Decorative and `aria-hidden`; only rendered with
102
+ * `labels.openUntil`.
103
+ */
104
+ openIndicator?: ReactNode;
99
105
  linkComponent?: MenuLinkComponent;
100
106
  className?: string;
101
107
  };
@@ -128,6 +134,7 @@ export function KitchenDossier({
128
134
  formatScore,
129
135
  partDurationMs,
130
136
  action,
137
+ openIndicator,
131
138
  linkComponent: LinkComponent = DefaultLink,
132
139
  className,
133
140
  }: KitchenDossierProps) {
@@ -249,7 +256,13 @@ export function KitchenDossier({
249
256
  <div className="relative min-h-[184px] flex-1">
250
257
  {labels.openUntil && (
251
258
  <span className="absolute left-0 top-3 inline-flex h-[30px] items-center gap-[7px] rounded-full bg-[oklch(0.16_0.012_55_/_0.78)] px-3 text-[11.5px] font-bold text-white">
252
- <i className="h-[7px] w-[7px] rounded-full bg-success" aria-hidden />
259
+ {openIndicator ? (
260
+ <span className="flex shrink-0 items-center justify-center" aria-hidden>
261
+ {openIndicator}
262
+ </span>
263
+ ) : (
264
+ <i className="h-[7px] w-[7px] rounded-full bg-success" aria-hidden />
265
+ )}
253
266
  {labels.openUntil}
254
267
  </span>
255
268
  )}
@@ -66,6 +66,12 @@ export type MenuHubHeroProps = {
66
66
  * `h2` so the document has one h1, not two.
67
67
  */
68
68
  headingLevel?: "h1" | "h2";
69
+ /**
70
+ * A decorative layer over the photographs and their scrim, under the text:
71
+ * fill the banner with `absolute inset-0`. Clipped by the banner's rounded
72
+ * edge. Decorative and `aria-hidden`.
73
+ */
74
+ sceneOverlay?: React.ReactNode;
69
75
  className?: string;
70
76
  };
71
77
 
@@ -91,6 +97,7 @@ export function MenuHubHero({
91
97
  backgroundUrl,
92
98
  tileUrls,
93
99
  headingLevel: Heading = "h1",
100
+ sceneOverlay,
94
101
  className,
95
102
  }: MenuHubHeroProps) {
96
103
  return (
@@ -157,6 +164,12 @@ export function MenuHubHero({
157
164
  )}
158
165
  />
159
166
 
167
+ {sceneOverlay && (
168
+ <div aria-hidden className="pointer-events-none absolute inset-0">
169
+ {sceneOverlay}
170
+ </div>
171
+ )}
172
+
160
173
  <div className="relative z-[1] flex w-full flex-col justify-center gap-3 p-5 text-white lg:gap-4 lg:px-12 lg:py-11">
161
174
  {kicker && (
162
175
  <span className="inline-flex w-max items-center gap-[7px] rounded-full bg-white/15 px-3 py-1.5 text-[11px] font-extrabold tracking-[0.11em]">
@@ -97,6 +97,16 @@ export const WithPhotoOverlay: Story = {
97
97
  },
98
98
  };
99
99
 
100
+ /** A caller's glyph in the open-now dot's slot (16-20px); the closed badge is unchanged. */
101
+ export const WithOpenIndicator: Story = {
102
+ args: {
103
+ place: { ...basePlace, open_now: true },
104
+ openIndicator: (
105
+ <span className="block h-[18px] w-[18px] rounded-full bg-amber-500 ring-2 ring-white/70" />
106
+ ),
107
+ },
108
+ };
109
+
100
110
  export const WithFooter: Story = {
101
111
  args: {
102
112
  footer: <span className="text-xs text-muted-foreground">Served as Margherita · 45 RON · 350g</span>,
@@ -291,6 +291,37 @@ describe("PlaceCard", () => {
291
291
  });
292
292
  });
293
293
 
294
+ describe("PlaceCard openIndicator", () => {
295
+ it("replaces the green dot when the place is open", () => {
296
+ const { container } = render(
297
+ <PlaceCard
298
+ place={makePlace({ open_now: true })}
299
+ labels={LABELS}
300
+ openIndicator={<i data-testid="indicator" />}
301
+ />
302
+ );
303
+ expect(screen.getByTestId("indicator")).toBeInTheDocument();
304
+ expect(container.querySelector(".bg-green-500")).not.toBeInTheDocument();
305
+ });
306
+
307
+ it("keeps the default dot without an indicator", () => {
308
+ const { container } = render(<PlaceCard place={makePlace({ open_now: true })} labels={LABELS} />);
309
+ expect(container.querySelector(".bg-green-500")).toBeInTheDocument();
310
+ });
311
+
312
+ it("does not render the indicator when closed, and keeps the badge", () => {
313
+ render(
314
+ <PlaceCard
315
+ place={makePlace({ open_now: false })}
316
+ labels={LABELS}
317
+ openIndicator={<i data-testid="indicator" />}
318
+ />
319
+ );
320
+ expect(screen.queryByTestId("indicator")).not.toBeInTheDocument();
321
+ expect(screen.getByText("Closed")).toBeInTheDocument();
322
+ });
323
+ });
324
+
294
325
  describe("PlaceCardSkeleton", () => {
295
326
  it("is hidden from assistive tech and reserves the footer when asked", () => {
296
327
  const { container, rerender } = render(<PlaceCardSkeleton />);
@@ -134,6 +134,13 @@ export type PlaceCardProps = {
134
134
  * trending badge on the API's `trending` flag).
135
135
  */
136
136
  photoOverlay?: ReactNode;
137
+ /**
138
+ * Drawn in place of the green open-now dot, in the dot's top-right slot
139
+ * (roughly 16-20px square). Decorative: it is only rendered when
140
+ * `place.open_now === true` and should be `aria-hidden`. The closed badge is
141
+ * untouched.
142
+ */
143
+ openIndicator?: ReactNode;
137
144
  className?: string;
138
145
  };
139
146
 
@@ -191,6 +198,7 @@ export const PlaceCard = memo(function PlaceCard({
191
198
  onOpen,
192
199
  footer,
193
200
  photoOverlay,
201
+ openIndicator,
194
202
  className,
195
203
  }: PlaceCardProps) {
196
204
  const [imgError, setImgError] = useState(false);
@@ -258,12 +266,20 @@ export const PlaceCard = memo(function PlaceCard({
258
266
  </div>
259
267
  )}
260
268
 
261
- {place.open_now === true && (
262
- <span
263
- className="absolute right-2.5 top-2.5 h-2 w-2 rounded-full bg-green-500 ring-2 ring-white/70"
264
- aria-hidden
265
- />
266
- )}
269
+ {place.open_now === true &&
270
+ (openIndicator ? (
271
+ <span
272
+ className="pointer-events-none absolute right-2 top-2 flex items-center justify-center"
273
+ aria-hidden
274
+ >
275
+ {openIndicator}
276
+ </span>
277
+ ) : (
278
+ <span
279
+ className="absolute right-2.5 top-2.5 h-2 w-2 rounded-full bg-green-500 ring-2 ring-white/70"
280
+ aria-hidden
281
+ />
282
+ ))}
267
283
  {place.open_now === false && (
268
284
  /* `Badge variant="overlay"` + the shared cover geometry: this pill and
269
285
  the caller's `photoOverlay` sit on the same photo and must match. */
@@ -60,6 +60,8 @@ export type PosterStackPoster = {
60
60
  caption?: string;
61
61
  /** Rendered when `src` is absent or fails. Defaults to `<EventPosterFallback />`. */
62
62
  fallback?: React.ReactNode;
63
+ /** Decorative overlay on the poster face (it tilts and floats with the poster): an absolutely positioned, `aria-hidden` ornament. */
64
+ overlay?: React.ReactNode;
63
65
  /** Load now at high priority instead of lazily. For the poster that is the
64
66
  * page's LCP element; every other poster stays lazy. */
65
67
  eager?: boolean;
@@ -207,6 +209,7 @@ export function PosterStack({
207
209
  </span>
208
210
  </span>
209
211
  )}
212
+ {poster.overlay}
210
213
  </>
211
214
  );
212
215
 
@@ -66,6 +66,11 @@ export type SectionHeaderProps = {
66
66
  * behaviour; it only holds the slot.
67
67
  */
68
68
  trailing?: React.ReactNode;
69
+ /**
70
+ * Absolute decoration anchored to the title group's own box. Takes no
71
+ * layout; the group is `relative` for it. Decorative and `aria-hidden`.
72
+ */
73
+ titleAdornment?: React.ReactNode;
69
74
  className?: string;
70
75
  };
71
76
 
@@ -98,6 +103,7 @@ export function SectionHeader({
98
103
  onAction,
99
104
  as: Heading = "h2",
100
105
  trailing,
106
+ titleAdornment,
101
107
  className,
102
108
  }: SectionHeaderProps) {
103
109
  const actionIcon = <ChevronRight className="h-3.5 w-3.5" aria-hidden />;
@@ -106,7 +112,7 @@ export function SectionHeader({
106
112
  return (
107
113
  <div className={cn("flex flex-col mb-3.5", className)}>
108
114
  <div className="flex items-center justify-between gap-2">
109
- <div className="flex min-w-0 items-baseline">
115
+ <div className="relative flex min-w-0 items-baseline">
110
116
  <Heading className="truncate text-[17px] font-bold tracking-[-0.01em] text-foreground md:text-[18px]">
111
117
  {title}
112
118
  </Heading>
@@ -115,6 +121,7 @@ export function SectionHeader({
115
121
  {count}
116
122
  </span>
117
123
  )}
124
+ {titleAdornment}
118
125
  </div>
119
126
 
120
127
  {/* The action and the trailing controls share the row's end, so the
@@ -18,10 +18,7 @@ const basePanels: Panel[] = [
18
18
  const panelWrappers = () =>
19
19
  Array.from(document.querySelectorAll<HTMLElement>('[data-slot="sliding-panel"]'));
20
20
 
21
- // The back button is icon-only, so it is the one button in the active panel
22
- // with no text content.
23
- const backButton = () =>
24
- screen.getAllByRole("button").filter((button) => button.textContent === "")[0];
21
+ const backButton = () => screen.getByRole("button", { name: "Back" });
25
22
 
26
23
  function Controlled({ initial = "one" }: { initial?: string }) {
27
24
  const [current, setCurrent] = useState(initial);
@@ -113,4 +110,29 @@ describe("SlidingPanels", () => {
113
110
  await user.click(backButton());
114
111
  expect(onPanelChange).toHaveBeenCalledWith("root");
115
112
  });
113
+
114
+ it("names the icon-only back button with backAriaLabel", () => {
115
+ const panels: Panel[] = [
116
+ { id: "root", content: <button type="button">Root</button> },
117
+ {
118
+ id: "child",
119
+ showBackButton: true,
120
+ backButtonLabel: "Categorii",
121
+ backAriaLabel: "Înapoi",
122
+ content: <button type="button">Child</button>,
123
+ },
124
+ ];
125
+ render(<SlidingPanels panels={panels} currentPanel="child" onPanelChange={vi.fn()} />);
126
+ // The heading beside it is the panel title, not the button's name
127
+ expect(screen.getByRole("button", { name: "Înapoi" })).toBeInTheDocument();
128
+ });
129
+
130
+ it("drops the slide and height transitions under reduced motion", () => {
131
+ render(<Controlled initial="two" />);
132
+ const container = document.querySelector<HTMLElement>('[data-slot="sliding-panels"]');
133
+ expect(container?.className).toContain("motion-reduce:transition-none");
134
+ for (const panel of panelWrappers()) {
135
+ expect(panel.className).toContain("motion-reduce:transition-none");
136
+ }
137
+ });
116
138
  });
@@ -10,7 +10,13 @@ export interface Panel {
10
10
  id: string;
11
11
  content: React.ReactNode;
12
12
  showBackButton?: boolean;
13
+ /** Heading shown next to the back button (the panel's title, not the button's name). */
13
14
  backButtonLabel?: string;
15
+ /**
16
+ * Accessible name of the icon-only back button, localized by the caller.
17
+ * Falls back to "Back"; without a name screen readers announce only "button".
18
+ */
19
+ backAriaLabel?: string;
14
20
  onBack?: () => void;
15
21
  // When set, the panel is treated as a child of `parentId`. Siblings (panels
16
22
  // sharing the same parent) stay off-screen on the right when not active —
@@ -84,7 +90,7 @@ export const SlidingPanels = ({
84
90
  <div
85
91
  data-slot="sliding-panels"
86
92
  className={cn(
87
- "relative overflow-clip transition-[height] duration-500 ease-[cubic-bezier(0.32,0.72,0,1)]",
93
+ "relative overflow-clip transition-[height] duration-500 ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none",
88
94
  className
89
95
  )}
90
96
  style={{ height: containerHeight, overflowClipMargin: "8px" }}
@@ -105,7 +111,7 @@ export const SlidingPanels = ({
105
111
  data-slot="sliding-panel"
106
112
  data-state={isActive ? "active" : "inactive"}
107
113
  className={cn(
108
- "absolute top-0 left-0 w-full transition-transform duration-500 ease-[cubic-bezier(0.32,0.72,0,1)]",
114
+ "absolute top-0 left-0 w-full transition-transform duration-500 ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none",
109
115
  isActive && "translate-x-0",
110
116
  isLeft && "-translate-x-[calc(100%+8px)]",
111
117
  isRight && "translate-x-[calc(100%+8px)]"
@@ -125,8 +131,13 @@ export const SlidingPanels = ({
125
131
  >
126
132
  {panel.showBackButton && (
127
133
  <div className="flex items-center gap-2 mb-4">
128
- <Button variant="ghost" size="icon" onClick={handleBack}>
129
- <ChevronLeft className="h-4 w-4" />
134
+ <Button
135
+ variant="ghost"
136
+ size="icon"
137
+ onClick={handleBack}
138
+ aria-label={panel.backAriaLabel ?? "Back"}
139
+ >
140
+ <ChevronLeft className="h-4 w-4" aria-hidden />
130
141
  </Button>
131
142
  {panel.backButtonLabel && (
132
143
  <h3 className="font-semibold">{panel.backButtonLabel}</h3>
@@ -135,6 +135,36 @@ export const FullDay: Story = {
135
135
  ),
136
136
  };
137
137
 
138
+ /**
139
+ * 02:00 on a Friday: last night's set is still on and leads the day in a live
140
+ * off-axis group (`tone="live"`) — on now, but with no slot on today's axis.
141
+ * Its cards keep the phone gutter, so they line up with tonight's hour below.
142
+ */
143
+ export const StillOnFromLastNight: Story = {
144
+ render: () => (
145
+ <div className="flex flex-col gap-3 py-6">
146
+ <EventDayHeader
147
+ dayNumber="09"
148
+ weekday="Friday"
149
+ meta="October · 2 events · 1 live"
150
+ size="responsive"
151
+ />
152
+ <TimelineDay rows={2}>
153
+ <TimelineHour time="Since last night" role="unscheduled" tone="live" countLabel="1 event" layout="rows">
154
+ <Card
155
+ ev={event("a", "Techno: Berlin calling", "Mansion", "23:00", 40)}
156
+ density="row-lg"
157
+ liveStatus="Live now · ends around 05:00"
158
+ />
159
+ </TimelineHour>
160
+ <TimelineHour time="21:00" countLabel="1 event">
161
+ <Card ev={event("b", "Comedy open mic", "Flying Circus", "21:00", 25)} />
162
+ </TimelineHour>
163
+ </TimelineDay>
164
+ </div>
165
+ ),
166
+ };
167
+
138
168
  /** A quiet Tuesday: one hour, one event, no hero — no lane on desktop either. */
139
169
  export const OneHour: Story = {
140
170
  render: () => (
@@ -58,6 +58,30 @@ describe("TimelineDay", () => {
58
58
  expect(container.querySelector(".font-mono.tabular-nums.whitespace-nowrap")).toBeNull();
59
59
  });
60
60
 
61
+ it("gives a live unscheduled group a live heading and the hours' phone gutter", () => {
62
+ const { container } = render(
63
+ <TimelineHour time="Since last night" role="unscheduled" tone="live" countLabel="1 event">
64
+ <p>set</p>
65
+ </TimelineHour>
66
+ );
67
+ const section = container.querySelector("section");
68
+ expect(section).toHaveAttribute("data-tone", "live");
69
+ expect(section).toHaveClass("pl-[70px]", "lg:pl-0");
70
+ expect(screen.getByText("Since last night")).toHaveClass("text-success", "font-semibold");
71
+ expect(screen.getByText("Since last night")).not.toHaveClass("font-mono");
72
+ // Still off the axis: no station.
73
+ expect(container.querySelector(".font-mono.tabular-nums.whitespace-nowrap")).toBeNull();
74
+ });
75
+
76
+ it("ignores tone on a stationed hour", () => {
77
+ const { container } = render(
78
+ <TimelineHour time="21:00" tone="live">
79
+ <p>a</p>
80
+ </TimelineHour>
81
+ );
82
+ expect(container.querySelector("section")).not.toHaveAttribute("data-tone");
83
+ });
84
+
61
85
  it("rules between rows but not between cards", () => {
62
86
  const { container: rows } = render(
63
87
  <TimelineHour time="21:00" layout="rows">
@@ -51,6 +51,15 @@ import { TIMELINE_MOBILE_GUTTER_CLASS, TimelineHourPuck } from "./timeline-hour-
51
51
  * label, a rule, the count) at both widths, after the day's
52
52
  * real programme.
53
53
  *
54
+ * `tone="live"` (with `unscheduled` only) is the same off-axis group for
55
+ * events that are ON NOW but have no slot on today's axis — last night's set
56
+ * still running at 02:00, day three of a festival. It leads the day, so the
57
+ * quiet mono heading ("time to be confirmed") would dress "on right now" as
58
+ * "we don't know when": the label goes sans, semibold and success-tinted
59
+ * behind a pulsing dot, the rule takes the live hairline's tint, and the
60
+ * phone keeps the hours' gutter inset so its cards line up with the cards
61
+ * below instead of running 70px wider and reading as the day's feature.
62
+ *
54
63
  * `layout`: `cards` stacks posters with a gap (a card is its own frame, a
55
64
  * hairline between two posters reads as a third object) and from `lg` lays
56
65
  * them on a two/three-up grid; `rows` is the same gapped stack on the phone
@@ -67,6 +76,9 @@ export type TimelineHourProps = {
67
76
  * the caller's label for the group ("Time to be confirmed"). */
68
77
  time: string;
69
78
  role?: TimelineHourRole;
79
+ /** `live`: an `unscheduled` group whose events are on now. Ignored on
80
+ * other roles, which say "live" through `isLive`. */
81
+ tone?: "default" | "live";
70
82
  /** Marks this as the hour currently in progress. */
71
83
  isLive?: boolean;
72
84
  /** Under the station when live, e.g. "NOW ON"; screen-reader-only in the chip. */
@@ -109,6 +121,7 @@ function ruleFromLg(items: React.ReactNode[]) {
109
121
  export function TimelineHour({
110
122
  time,
111
123
  role = "hour",
124
+ tone = "default",
112
125
  isLive,
113
126
  liveLabel,
114
127
  countLabel,
@@ -121,6 +134,7 @@ export function TimelineHour({
121
134
  const isHero = role === "hero";
122
135
  const isContinuation = role === "continuation";
123
136
  const isUnscheduled = role === "unscheduled";
137
+ const isLiveGroup = isUnscheduled && tone === "live";
124
138
  const hasStation = !isUnscheduled;
125
139
  const isCards = layout === "cards";
126
140
  const items = React.Children.toArray(children);
@@ -129,10 +143,12 @@ export function TimelineHour({
129
143
  return (
130
144
  <section
131
145
  data-timeline-hour={role}
146
+ data-tone={isLiveGroup ? "live" : undefined}
132
147
  className={cn(
133
148
  "relative flex flex-col pt-2.5",
134
149
  // The phone gutter: the puck's column plus the 12px gap `HourBand` had.
135
- hasStation && "pl-[70px] lg:pl-0",
150
+ // A live group keeps it so its cards align with the hours' cards.
151
+ (hasStation || isLiveGroup) && "pl-[70px] lg:pl-0",
136
152
  "lg:pt-[22px]",
137
153
  isHero
138
154
  ? cn(
@@ -183,17 +199,35 @@ export function TimelineHour({
183
199
  className={cn(
184
200
  "items-center gap-2.5",
185
201
  isUnscheduled ? "flex h-4" : "hidden lg:flex lg:h-4",
186
- isHero && "lg:hidden"
202
+ isHero && "lg:hidden",
203
+ // The heading spans the gutter; only the cards sit inset.
204
+ isLiveGroup && "-ml-[70px] lg:ml-0"
187
205
  )}
188
206
  >
189
- {isUnscheduled && (
190
- <span className="font-mono text-[11.5px] text-muted-foreground tabular-nums lg:text-xs">
207
+ {isLiveGroup ? (
208
+ <span className="flex items-center gap-1.5 text-xs font-semibold text-success">
209
+ <span
210
+ aria-hidden="true"
211
+ className="size-1.5 shrink-0 animate-pulse rounded-full bg-success motion-reduce:animate-none"
212
+ />
191
213
  {time}
192
214
  </span>
215
+ ) : (
216
+ isUnscheduled && (
217
+ <span className="font-mono text-[11.5px] text-muted-foreground tabular-nums lg:text-xs">
218
+ {time}
219
+ </span>
220
+ )
193
221
  )}
194
- <Separator className="flex-1" />
222
+ <Separator className={cn("flex-1", isLiveGroup && "bg-success/30")} />
195
223
  {countLabel && (
196
- <span className="text-[10.5px] text-muted-foreground/70 lg:text-[11px]">
224
+ <span
225
+ className={cn(
226
+ isLiveGroup
227
+ ? "text-xs text-muted-foreground"
228
+ : "text-[10.5px] text-muted-foreground/70 lg:text-[11px]"
229
+ )}
230
+ >
197
231
  {countLabel}
198
232
  </span>
199
233
  )}
@@ -0,0 +1,26 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ /**
4
+ * A row's poster with a caller's decoration laid over its box, unclipped (the
5
+ * poster itself clips). Without an overlay it is the poster alone, with no
6
+ * extra element, so a row without one renders exactly as before. The frame
7
+ * is the poster's flex slot (`shrink-0`) and the poster's size, so the row's
8
+ * layout is identical with or without it.
9
+ */
10
+ export function PosterOverlayFrame({
11
+ overlay,
12
+ children,
13
+ }: {
14
+ overlay?: ReactNode;
15
+ children: ReactNode;
16
+ }) {
17
+ if (!overlay) return <>{children}</>;
18
+ return (
19
+ <div className="relative shrink-0">
20
+ {children}
21
+ <div className="pointer-events-none absolute inset-0 z-[1]" aria-hidden="true">
22
+ {overlay}
23
+ </div>
24
+ </div>
25
+ );
26
+ }