@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
@@ -81,6 +81,13 @@ type PlaceCardProps = {
81
81
  * trending badge on the API's `trending` flag).
82
82
  */
83
83
  photoOverlay?: ReactNode;
84
+ /**
85
+ * Drawn in place of the green open-now dot, in the dot's top-right slot
86
+ * (roughly 16-20px square). Decorative: it is only rendered when
87
+ * `place.open_now === true` and should be `aria-hidden`. The closed badge is
88
+ * untouched.
89
+ */
90
+ openIndicator?: ReactNode;
84
91
  className?: string;
85
92
  };
86
93
  declare const PlaceCard: React$1.NamedExoticComponent<PlaceCardProps>;
@@ -74,6 +74,7 @@ var PlaceCard = memo(function PlaceCard2({
74
74
  onOpen,
75
75
  footer,
76
76
  photoOverlay,
77
+ openIndicator,
77
78
  className
78
79
  }) {
79
80
  var _a, _b, _c, _d, _e, _f, _g, _h, _i;
@@ -132,13 +133,20 @@ var PlaceCard = memo(function PlaceCard2({
132
133
  }
133
134
  ) })
134
135
  ),
135
- place.open_now === true && /* @__PURE__ */ jsx(
136
+ place.open_now === true && (openIndicator ? /* @__PURE__ */ jsx(
137
+ "span",
138
+ {
139
+ className: "pointer-events-none absolute right-2 top-2 flex items-center justify-center",
140
+ "aria-hidden": true,
141
+ children: openIndicator
142
+ }
143
+ ) : /* @__PURE__ */ jsx(
136
144
  "span",
137
145
  {
138
146
  className: "absolute right-2.5 top-2.5 h-2 w-2 rounded-full bg-green-500 ring-2 ring-white/70",
139
147
  "aria-hidden": true
140
148
  }
141
- ),
149
+ )),
142
150
  place.open_now === false && /* `Badge variant="overlay"` + the shared cover geometry: this pill and
143
151
  the caller's `photoOverlay` sit on the same photo and must match. */
144
152
  /* @__PURE__ */ jsx(Badge, { variant: "overlay", className: cn(OVERLAY_PILL, "absolute right-2 top-2"), children: labels.closed })
@@ -50,6 +50,8 @@ type PosterStackPoster = {
50
50
  caption?: string;
51
51
  /** Rendered when `src` is absent or fails. Defaults to `<EventPosterFallback />`. */
52
52
  fallback?: React.ReactNode;
53
+ /** Decorative overlay on the poster face (it tilts and floats with the poster): an absolutely positioned, `aria-hidden` ornament. */
54
+ overlay?: React.ReactNode;
53
55
  /** Load now at high priority instead of lazily. For the poster that is the
54
56
  * page's LCP element; every other poster stays lazy. */
55
57
  eager?: boolean;
@@ -96,7 +96,8 @@ function PosterStack({
96
96
  }
97
97
  )
98
98
  ) : (_a = poster.fallback) != null ? _a : /* @__PURE__ */ jsx(EventPosterFallback, {}),
99
- poster.caption && /* @__PURE__ */ jsx("span", { className: "absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent px-2 pb-2 pt-6", children: /* @__PURE__ */ jsx("span", { className: "line-clamp-2 text-[11px] font-semibold leading-[1.25] text-white md:text-[12.5px]", children: poster.caption }) })
99
+ poster.caption && /* @__PURE__ */ jsx("span", { className: "absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent px-2 pb-2 pt-6", children: /* @__PURE__ */ jsx("span", { className: "line-clamp-2 text-[11px] font-semibold leading-[1.25] text-white md:text-[12.5px]", children: poster.caption }) }),
100
+ poster.overlay
100
101
  ] });
101
102
  return /* @__PURE__ */ jsx(
102
103
  "div",
@@ -56,9 +56,14 @@ type SectionHeaderProps = {
56
56
  * behaviour; it only holds the slot.
57
57
  */
58
58
  trailing?: React.ReactNode;
59
+ /**
60
+ * Absolute decoration anchored to the title group's own box. Takes no
61
+ * layout; the group is `relative` for it. Decorative and `aria-hidden`.
62
+ */
63
+ titleAdornment?: React.ReactNode;
59
64
  className?: string;
60
65
  };
61
- declare function SectionHeader({ title, count, subtitle, action, actionHref, actionTone, subtitleFrom, linkComponent: LinkComponent, onAction, as: Heading, trailing, className, }: SectionHeaderProps): react_jsx_runtime.JSX.Element;
66
+ declare function SectionHeader({ title, count, subtitle, action, actionHref, actionTone, subtitleFrom, linkComponent: LinkComponent, onAction, as: Heading, trailing, titleAdornment, className, }: SectionHeaderProps): react_jsx_runtime.JSX.Element;
62
67
  type SectionHeaderSkeletonProps = {
63
68
  /** Shows the inline count placeholder, matching `SectionHeaderProps["count"]`. */
64
69
  showCount?: boolean;
@@ -32,15 +32,17 @@ function SectionHeader({
32
32
  onAction,
33
33
  as: Heading = "h2",
34
34
  trailing,
35
+ titleAdornment,
35
36
  className
36
37
  }) {
37
38
  const actionIcon = /* @__PURE__ */ jsx(ChevronRight, { className: "h-3.5 w-3.5", "aria-hidden": true });
38
39
  const actionClasses = cn(ACTION_BASE_CLASSES, ACTION_TONE_CLASSES[actionTone]);
39
40
  return /* @__PURE__ */ jsxs("div", { className: cn("flex flex-col mb-3.5", className), children: [
40
41
  /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2", children: [
41
- /* @__PURE__ */ jsxs("div", { className: "flex min-w-0 items-baseline", children: [
42
+ /* @__PURE__ */ jsxs("div", { className: "relative flex min-w-0 items-baseline", children: [
42
43
  /* @__PURE__ */ jsx(Heading, { className: "truncate text-[17px] font-bold tracking-[-0.01em] text-foreground md:text-[18px]", children: title }),
43
- count !== void 0 && /* @__PURE__ */ jsx("span", { className: "ml-1.5 shrink-0 text-[12px] tabular-nums text-muted-foreground lg:ml-0.5", children: count })
44
+ count !== void 0 && /* @__PURE__ */ jsx("span", { className: "ml-1.5 shrink-0 text-[12px] tabular-nums text-muted-foreground lg:ml-0.5", children: count }),
45
+ titleAdornment
44
46
  ] }),
45
47
  (action || trailing) && /* @__PURE__ */ jsxs("div", { className: "flex shrink-0 items-center gap-3", children: [
46
48
  action && (actionHref ? /* @__PURE__ */ jsxs(LinkComponent, { href: actionHref, className: actionClasses, onClick: onAction, children: [
@@ -5,7 +5,13 @@ interface Panel {
5
5
  id: string;
6
6
  content: React.ReactNode;
7
7
  showBackButton?: boolean;
8
+ /** Heading shown next to the back button (the panel's title, not the button's name). */
8
9
  backButtonLabel?: string;
10
+ /**
11
+ * Accessible name of the icon-only back button, localized by the caller.
12
+ * Falls back to "Back"; without a name screen readers announce only "button".
13
+ */
14
+ backAriaLabel?: string;
9
15
  onBack?: () => void;
10
16
  parentId?: string;
11
17
  }
@@ -56,11 +56,12 @@ var SlidingPanels = ({
56
56
  {
57
57
  "data-slot": "sliding-panels",
58
58
  className: cn(
59
- "relative overflow-clip transition-[height] duration-500 ease-[cubic-bezier(0.32,0.72,0,1)]",
59
+ "relative overflow-clip transition-[height] duration-500 ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none",
60
60
  className
61
61
  ),
62
62
  style: { height: containerHeight, overflowClipMargin: "8px" },
63
63
  children: panels.map((panel, index) => {
64
+ var _a;
64
65
  const isActive = panel.id === currentPanel;
65
66
  const isLeft = usesTreeModel ? activeAncestors.has(panel.id) : index < currentIndex;
66
67
  const isRight = !isActive && !isLeft;
@@ -73,7 +74,7 @@ var SlidingPanels = ({
73
74
  "data-slot": "sliding-panel",
74
75
  "data-state": isActive ? "active" : "inactive",
75
76
  className: cn(
76
- "absolute top-0 left-0 w-full transition-transform duration-500 ease-[cubic-bezier(0.32,0.72,0,1)]",
77
+ "absolute top-0 left-0 w-full transition-transform duration-500 ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none",
77
78
  isActive && "translate-x-0",
78
79
  isLeft && "-translate-x-[calc(100%+8px)]",
79
80
  isRight && "translate-x-[calc(100%+8px)]"
@@ -82,7 +83,16 @@ var SlidingPanels = ({
82
83
  "aria-hidden": !isActive,
83
84
  children: [
84
85
  panel.showBackButton && /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 mb-4", children: [
85
- /* @__PURE__ */ jsx(Button, { variant: "ghost", size: "icon", onClick: handleBack, children: /* @__PURE__ */ jsx(ChevronLeft, { className: "h-4 w-4" }) }),
86
+ /* @__PURE__ */ jsx(
87
+ Button,
88
+ {
89
+ variant: "ghost",
90
+ size: "icon",
91
+ onClick: handleBack,
92
+ "aria-label": (_a = panel.backAriaLabel) != null ? _a : "Back",
93
+ children: /* @__PURE__ */ jsx(ChevronLeft, { className: "h-4 w-4", "aria-hidden": true })
94
+ }
95
+ ),
86
96
  panel.backButtonLabel && /* @__PURE__ */ jsx("h3", { className: "font-semibold", children: panel.backButtonLabel })
87
97
  ] }),
88
98
  panel.content
@@ -4,11 +4,11 @@ import { VariantProps } from 'class-variance-authority';
4
4
 
5
5
  declare const spinnerVariants: (props?: ({
6
6
  size?: "default" | "xs" | "sm" | "lg" | "xl" | null | undefined;
7
- tone?: "default" | "destructive" | "inherit" | "primary" | null | undefined;
7
+ tone?: "primary" | "default" | "inherit" | "destructive" | null | undefined;
8
8
  } & class_variance_authority_types.ClassProp) | undefined) => string;
9
9
  declare const Spinner: React.ForwardRefExoticComponent<React.HTMLAttributes<HTMLSpanElement> & VariantProps<(props?: ({
10
10
  size?: "default" | "xs" | "sm" | "lg" | "xl" | null | undefined;
11
- tone?: "default" | "destructive" | "inherit" | "primary" | null | undefined;
11
+ tone?: "primary" | "default" | "inherit" | "destructive" | null | undefined;
12
12
  } & class_variance_authority_types.ClassProp) | undefined) => string> & {
13
13
  /** Visually-hidden label for screen readers. Defaults to "Loading". */
14
14
  label?: string;
@@ -3,11 +3,11 @@ import * as React from 'react';
3
3
  import { VariantProps } from 'class-variance-authority';
4
4
 
5
5
  declare const tagVariants: (props?: ({
6
- variant?: "default" | "destructive" | "warning" | "success" | "primary" | "outline" | "quiet" | null | undefined;
6
+ variant?: "primary" | "default" | "success" | "warning" | "destructive" | "outline" | "quiet" | null | undefined;
7
7
  size?: "default" | "sm" | "lg" | null | undefined;
8
8
  } & class_variance_authority_types.ClassProp) | undefined) => string;
9
9
  declare const Tag: React.ForwardRefExoticComponent<React.HTMLAttributes<HTMLSpanElement> & VariantProps<(props?: ({
10
- variant?: "default" | "destructive" | "warning" | "success" | "primary" | "outline" | "quiet" | null | undefined;
10
+ variant?: "primary" | "default" | "success" | "warning" | "destructive" | "outline" | "quiet" | null | undefined;
11
11
  size?: "default" | "sm" | "lg" | null | undefined;
12
12
  } & class_variance_authority_types.ClassProp) | undefined) => string> & {
13
13
  onRemove?: () => void;
@@ -22,9 +22,9 @@ import { VariantProps } from 'class-variance-authority';
22
22
  * Weights are 400 / 600 / 700. 500 is not in the system.
23
23
  */
24
24
  declare const textVariants: (props?: ({
25
- variant?: "small" | "display" | "h1" | "h2" | "h3" | "h4" | "body-lg" | "body" | "body-sm" | "caption" | "label" | "overline" | null | undefined;
26
- color?: "default" | "destructive" | "inherit" | "muted" | "primary" | "primary-foreground" | null | undefined;
27
- weight?: "bold" | "normal" | "medium" | "semibold" | null | undefined;
25
+ variant?: "small" | "body" | "display" | "h2" | "h3" | "label" | "caption" | "h1" | "h4" | "body-lg" | "body-sm" | "overline" | null | undefined;
26
+ color?: "primary" | "default" | "inherit" | "destructive" | "muted" | "primary-foreground" | null | undefined;
27
+ weight?: "bold" | "medium" | "normal" | "semibold" | null | undefined;
28
28
  leading?: "none" | "normal" | "tight" | "snug" | "relaxed" | "loose" | null | undefined;
29
29
  tracking?: "normal" | "tight" | "wide" | "wider" | null | undefined;
30
30
  wrap?: "balance" | "pretty" | "nowrap" | null | undefined;
@@ -1,4 +1,4 @@
1
- import { TimelineHourSkeleton } from '../chunk-S5GDARLW.js';
1
+ import { TimelineHourSkeleton } from '../chunk-WY2FP76J.js';
2
2
  import '../chunk-BRXI6EDO.js';
3
3
  import { Separator } from '../chunk-NWZGSLPU.js';
4
4
  import { Skeleton } from '../chunk-XBBEX4SF.js';
@@ -46,6 +46,15 @@ import * as React from 'react';
46
46
  * label, a rule, the count) at both widths, after the day's
47
47
  * real programme.
48
48
  *
49
+ * `tone="live"` (with `unscheduled` only) is the same off-axis group for
50
+ * events that are ON NOW but have no slot on today's axis — last night's set
51
+ * still running at 02:00, day three of a festival. It leads the day, so the
52
+ * quiet mono heading ("time to be confirmed") would dress "on right now" as
53
+ * "we don't know when": the label goes sans, semibold and success-tinted
54
+ * behind a pulsing dot, the rule takes the live hairline's tint, and the
55
+ * phone keeps the hours' gutter inset so its cards line up with the cards
56
+ * below instead of running 70px wider and reading as the day's feature.
57
+ *
49
58
  * `layout`: `cards` stacks posters with a gap (a card is its own frame, a
50
59
  * hairline between two posters reads as a third object) and from `lg` lays
51
60
  * them on a two/three-up grid; `rows` is the same gapped stack on the phone
@@ -60,6 +69,9 @@ type TimelineHourProps = {
60
69
  * the caller's label for the group ("Time to be confirmed"). */
61
70
  time: string;
62
71
  role?: TimelineHourRole;
72
+ /** `live`: an `unscheduled` group whose events are on now. Ignored on
73
+ * other roles, which say "live" through `isLive`. */
74
+ tone?: "default" | "live";
63
75
  /** Marks this as the hour currently in progress. */
64
76
  isLive?: boolean;
65
77
  /** Under the station when live, e.g. "NOW ON"; screen-reader-only in the chip. */
@@ -81,7 +93,7 @@ type TimelineHourProps = {
81
93
  overflow?: React.ReactNode;
82
94
  className?: string;
83
95
  };
84
- declare function TimelineHour({ time, role, isLive, liveLabel, countLabel, note, layout, children, overflow, className, }: TimelineHourProps): react_jsx_runtime.JSX.Element;
96
+ declare function TimelineHour({ time, role, tone, isLive, liveLabel, countLabel, note, layout, children, overflow, className, }: TimelineHourProps): react_jsx_runtime.JSX.Element;
85
97
  type TimelineHourSkeletonProps = {
86
98
  /** Cards to reserve space for. */
87
99
  cardCount?: number;
@@ -1,4 +1,4 @@
1
- export { TimelineHour, TimelineHourSkeleton } from '../chunk-S5GDARLW.js';
1
+ export { TimelineHour, TimelineHourSkeleton } from '../chunk-WY2FP76J.js';
2
2
  import '../chunk-BRXI6EDO.js';
3
3
  import '../chunk-NWZGSLPU.js';
4
4
  import '../chunk-XBBEX4SF.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bearmenu/ui",
3
- "version": "0.8.50",
3
+ "version": "0.8.52",
4
4
  "packageManager": "pnpm@10.28.1",
5
5
  "description": "BearMenu design system — shared UI tokens, primitives, and components",
6
6
  "license": "UNLICENSED",
@@ -105,6 +105,11 @@ export type CategoryDeckProps = {
105
105
  /** Defaults to a plain `<a>`. Pass `next/link` in a Next app. */
106
106
  linkComponent?: React.ComponentType<CategoryDeckLinkProps>;
107
107
  onClick?: (mouseEvent: React.MouseEvent) => void;
108
+ /**
109
+ * Decoration laid over the fan's box, unclipped (it may cross the box's
110
+ * edge), above the cards. Absolutely positioned, `aria-hidden`, no layout.
111
+ */
112
+ overlay?: React.ReactNode;
108
113
  className?: string;
109
114
  };
110
115
 
@@ -115,6 +120,7 @@ export function CategoryDeck({
115
120
  href,
116
121
  linkComponent: LinkComponent = DefaultLink,
117
122
  onClick,
123
+ overlay,
118
124
  className,
119
125
  }: CategoryDeckProps) {
120
126
  const slots = Array.from(
@@ -160,6 +166,11 @@ export function CategoryDeck({
160
166
  </div>
161
167
  );
162
168
  })}
169
+ {overlay && (
170
+ <span className="pointer-events-none absolute inset-0 z-[3] block" aria-hidden>
171
+ {overlay}
172
+ </span>
173
+ )}
163
174
  </div>
164
175
 
165
176
  <div className="flex items-center gap-[9px] pt-3.5">
@@ -185,4 +185,10 @@ describe("EventCard", () => {
185
185
  renderCard({ liveStatus: "Live now · ends around 23:00" });
186
186
  expect(screen.getByText("Live now · ends around 23:00")).toBeInTheDocument();
187
187
  });
188
+
189
+ it("renders posterOverlay inside a real, pointer-transparent box over the poster", () => {
190
+ renderCard({ posterOverlay: <i data-testid="overlay" /> });
191
+ const box = screen.getByTestId("overlay").parentElement;
192
+ expect(box).toHaveClass("absolute", "inset-0", "pointer-events-none");
193
+ });
188
194
  });
@@ -75,6 +75,13 @@ export type EventCardProps = {
75
75
  badge?: React.ReactNode;
76
76
  /** Bottom-left overlay pill naming the category. Include its icon. */
77
77
  tagLabel?: React.ReactNode;
78
+ /**
79
+ * Decorative overlay inside the poster box (which is `relative` and clips),
80
+ * for a caller's absolutely positioned ornament. Rendered after the pills,
81
+ * hidden on the compact row's 64px poster. Must be `aria-hidden` and
82
+ * pointer-transparent; it never takes layout.
83
+ */
84
+ posterOverlay?: React.ReactNode;
78
85
  /**
79
86
  * Caller-supplied display labels, rendered as small chips in the text area
80
87
  * beneath the venue/meta line. `EventPreview.tags` is taxonomy coordinates
@@ -132,6 +139,7 @@ export const EventCard = memo(function EventCard({
132
139
  density = "card",
133
140
  badge,
134
141
  tagLabel,
142
+ posterOverlay,
135
143
  tags,
136
144
  liveStatus,
137
145
  hideWhen,
@@ -228,6 +236,15 @@ export const EventCard = memo(function EventCard({
228
236
  </span>
229
237
  </div>
230
238
  )}
239
+
240
+ {posterOverlay && (
241
+ <div
242
+ className={cn("pointer-events-none absolute inset-0", isRowLg && "lg:hidden")}
243
+ aria-hidden="true"
244
+ >
245
+ {posterOverlay}
246
+ </div>
247
+ )}
231
248
  </div>
232
249
 
233
250
  {/* The text column — a wrapper so `row-lg` can lay it beside the poster. */}
@@ -4,6 +4,7 @@ import { memo, useState } from "react";
4
4
  import { Radio } from "lucide-react";
5
5
  import { cn } from "../lib/utils";
6
6
  import { EventPosterFallback } from "./event-poster-fallback";
7
+ import { PosterOverlayFrame } from "../lib/poster-overlay-frame";
7
8
  import { Skeleton } from "./skeleton";
8
9
  import { Tag } from "./tag";
9
10
  import { TagGroup } from "./tag-group";
@@ -105,6 +106,12 @@ export type EventCompactRowProps = {
105
106
  priority?: boolean;
106
107
  /** Rendered in place of the poster when it is absent or fails to load. Defaults to `<EventPosterFallback />`. */
107
108
  fallback?: React.ReactNode;
109
+ /**
110
+ * Decoration laid over the poster's box, unclipped (it may cross the
111
+ * poster's edge), above the row. Absolutely positioned, `aria-hidden`,
112
+ * pointer-transparent; it takes no layout.
113
+ */
114
+ posterOverlay?: React.ReactNode;
108
115
  };
109
116
 
110
117
  export const EventCompactRow = memo(function EventCompactRow({
@@ -125,6 +132,7 @@ export const EventCompactRow = memo(function EventCompactRow({
125
132
  className,
126
133
  priority,
127
134
  fallback = <EventPosterFallback />,
135
+ posterOverlay,
128
136
  }: EventCompactRowProps) {
129
137
  const [imgError, setImgError] = useState(false);
130
138
  const isSm = size === "sm";
@@ -169,30 +177,32 @@ export const EventCompactRow = memo(function EventCompactRow({
169
177
  phone-scoped: a 14px bold title and 11px of vertical padding below
170
178
  `lg`; from `lg` the 13px semibold title and 8px padding the desktop
171
179
  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
- >
178
- {showPoster ? (
179
- // eslint-disable-next-line @next/next/no-img-element
180
- <img
181
- src={event.poster_url}
182
- alt=""
183
- loading={priority ? "eager" : "lazy"}
184
- decoding="async"
185
- className="h-full w-full object-cover"
186
- onError={() => setImgError(true)}
187
- />
188
- ) : (
189
- // A bare `bg-muted` rectangle was survivable at 34px. At 58x72 beside
190
- // real posters it reads as a broken image rather than an absent one —
191
- // so this row finally joins every sibling in the family in having a
192
- // designed fallback.
193
- fallback
194
- )}
195
- </div>
180
+ <PosterOverlayFrame overlay={posterOverlay}>
181
+ <div
182
+ className={cn(
183
+ "relative aspect-[4/5] w-16 shrink-0 overflow-hidden rounded-[10px] bg-muted",
184
+ isEnded && "opacity-45",
185
+ )}
186
+ >
187
+ {showPoster ? (
188
+ // eslint-disable-next-line @next/next/no-img-element
189
+ <img
190
+ src={event.poster_url}
191
+ alt=""
192
+ loading={priority ? "eager" : "lazy"}
193
+ decoding="async"
194
+ className="h-full w-full object-cover"
195
+ onError={() => setImgError(true)}
196
+ />
197
+ ) : (
198
+ // A bare `bg-muted` rectangle was survivable at 34px. At 58x72 beside
199
+ // real posters it reads as a broken image rather than an absent one —
200
+ // so this row finally joins every sibling in the family in having a
201
+ // designed fallback.
202
+ fallback
203
+ )}
204
+ </div>
205
+ </PosterOverlayFrame>
196
206
 
197
207
  <div className="min-w-0 flex-1">
198
208
  {/* The title wraps in full — never truncated, in this row or any of
@@ -29,6 +29,11 @@ export type EventHubHeroStat = {
29
29
  label: string;
30
30
  /** `live` marks the figure that is happening right now. */
31
31
  tone?: "default" | "live";
32
+ /**
33
+ * Decoration beside the figure, absolutely positioned just past its right
34
+ * edge: no layout. Decorative and `aria-hidden`.
35
+ */
36
+ adornment?: React.ReactNode;
32
37
  };
33
38
 
34
39
  export type EventHubHeroProps = {
@@ -131,10 +136,19 @@ export function EventHubHero({
131
136
  className={cn(
132
137
  "text-[19px] font-black leading-none tracking-[-0.02em] tabular-nums",
133
138
  isLive ? "text-success" : "text-foreground",
134
- "lg:text-[30px] lg:tracking-[-0.03em]"
139
+ "lg:text-[30px] lg:tracking-[-0.03em]",
140
+ stat.adornment && "relative w-fit"
135
141
  )}
136
142
  >
137
143
  {stat.value}
144
+ {stat.adornment && (
145
+ <span
146
+ className="pointer-events-none absolute left-full top-1/2 ml-1.5 -translate-y-1/2"
147
+ aria-hidden
148
+ >
149
+ {stat.adornment}
150
+ </span>
151
+ )}
138
152
  </span>
139
153
  <span className="mt-0.5 text-[11px] text-muted-foreground lg:mt-[5px] lg:text-[12.5px]">
140
154
  {stat.label}
@@ -6,6 +6,7 @@ import { MapPin, Radio } from "lucide-react";
6
6
  import { cn } from "../lib/utils";
7
7
  import { buttonVariants } from "./button";
8
8
  import { EventPosterFallback } from "./event-poster-fallback";
9
+ import { PosterOverlayFrame } from "../lib/poster-overlay-frame";
9
10
  import { Separator } from "./separator";
10
11
  import { Skeleton } from "./skeleton";
11
12
  import { Tag } from "./tag";
@@ -106,6 +107,12 @@ export type EventListRowWideProps = {
106
107
  * line. Same prop and same reason as `EventIndexRow`.
107
108
  */
108
109
  hideRule?: boolean;
110
+ /**
111
+ * Decoration laid over the thumbnail's box, unclipped (it may cross the
112
+ * thumbnail's edge). Absolutely positioned, `aria-hidden`,
113
+ * pointer-transparent; it takes no layout.
114
+ */
115
+ posterOverlay?: ReactNode;
109
116
  };
110
117
 
111
118
  export const EventListRowWide = memo(function EventListRowWide({
@@ -123,6 +130,7 @@ export const EventListRowWide = memo(function EventListRowWide({
123
130
  priority,
124
131
  fallback = <EventPosterFallback />,
125
132
  hideRule,
133
+ posterOverlay,
126
134
  }: EventListRowWideProps) {
127
135
  const [imgError, setImgError] = useState(false);
128
136
 
@@ -178,21 +186,23 @@ export const EventListRowWide = memo(function EventListRowWide({
178
186
  </div>
179
187
  </div>
180
188
 
181
- <div className="aspect-square w-[104px] shrink-0 overflow-hidden rounded-[10px] bg-muted">
182
- {showPoster ? (
183
- // eslint-disable-next-line @next/next/no-img-element
184
- <img
185
- src={event.poster_url}
186
- alt=""
187
- loading={priority ? "eager" : "lazy"}
188
- decoding="async"
189
- className="h-full w-full object-cover"
190
- onError={() => setImgError(true)}
191
- />
192
- ) : (
193
- fallback
194
- )}
195
- </div>
189
+ <PosterOverlayFrame overlay={posterOverlay}>
190
+ <div className="aspect-square w-[104px] shrink-0 overflow-hidden rounded-[10px] bg-muted">
191
+ {showPoster ? (
192
+ // eslint-disable-next-line @next/next/no-img-element
193
+ <img
194
+ src={event.poster_url}
195
+ alt=""
196
+ loading={priority ? "eager" : "lazy"}
197
+ decoding="async"
198
+ className="h-full w-full object-cover"
199
+ onError={() => setImgError(true)}
200
+ />
201
+ ) : (
202
+ fallback
203
+ )}
204
+ </div>
205
+ </PosterOverlayFrame>
196
206
 
197
207
  <div className="min-w-0 flex-1">
198
208
  <div className="flex flex-wrap items-center gap-2">
@@ -21,6 +21,18 @@ describe("EventTonightStrip", () => {
21
21
  );
22
22
  });
23
23
 
24
+ it("draws the live indicator in place of the pulsing dot", () => {
25
+ render(
26
+ <EventTonightStrip
27
+ tone="live"
28
+ lead="3 live now"
29
+ href="/events/schedule"
30
+ liveIndicator={<i data-testid="flame" />}
31
+ />
32
+ );
33
+ expect(screen.getByTestId("flame")).toBeInTheDocument();
34
+ });
35
+
24
36
  it("renders a status line, not a link, when there is nothing to open", () => {
25
37
  render(<EventTonightStrip tone="none" lead="Nothing scheduled" />);
26
38
  expect(screen.queryByRole("link")).toBeNull();
@@ -51,6 +51,8 @@ export type EventTonightStripProps = {
51
51
  href?: string;
52
52
  /** Defaults to a plain `<a>`. Pass `next/link` in a Next app. */
53
53
  linkComponent?: React.ComponentType<EventTonightStripLinkProps>;
54
+ /** Replaces the pulsing dot of the `live` tone, in the dot's own slot. Decorative. */
55
+ liveIndicator?: React.ReactNode;
54
56
  className?: string;
55
57
  };
56
58
 
@@ -61,12 +63,17 @@ export function EventTonightStrip({
61
63
  actionLabel,
62
64
  href,
63
65
  linkComponent: LinkComponent = DefaultLink,
66
+ liveIndicator,
64
67
  className,
65
68
  }: EventTonightStripProps) {
66
69
  const interactive = !!href;
67
70
 
68
71
  const mark =
69
- tone === "live" ? (
72
+ tone === "live" && liveIndicator ? (
73
+ <span className="flex h-4 w-4 items-center justify-center" aria-hidden>
74
+ {liveIndicator}
75
+ </span>
76
+ ) : tone === "live" ? (
70
77
  <span className="relative flex h-2.5 w-2.5" aria-hidden>
71
78
  <span className="absolute inline-flex h-full w-full rounded-full bg-success/40 motion-live-pulse motion-reduce:animate-none" />
72
79
  <span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-success" />
@@ -34,6 +34,8 @@ export type FeaturedRailItemProps = {
34
34
  note: string;
35
35
  /** Top-left overlay pill. Include its icon. */
36
36
  tagLabel?: React.ReactNode;
37
+ /** Decorative overlay inside the poster box (`relative`, clipping): an absolutely positioned, `aria-hidden` ornament. */
38
+ posterOverlay?: React.ReactNode;
37
39
  posterUrl?: string;
38
40
  href?: string;
39
41
  /** Defaults to a plain `<a>`. Pass `next/link` in a Next app. */
@@ -49,6 +51,7 @@ export function FeaturedRailItem({
49
51
  venue,
50
52
  note,
51
53
  tagLabel,
54
+ posterOverlay,
52
55
  posterUrl,
53
56
  href,
54
57
  linkComponent: LinkComponent = DefaultLink,
@@ -84,6 +87,8 @@ export function FeaturedRailItem({
84
87
  </span>
85
88
  </div>
86
89
  )}
90
+
91
+ {posterOverlay}
87
92
  </div>
88
93
 
89
94
  <div className="px-0.5 pt-2.5">