@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,201 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { TimelineDay, TimelineDaySkeleton } from "./timeline-day";
3
+ import { TimelineHour } from "./timeline-hour";
4
+ import { EventCard } from "./event-card";
5
+ import { EventDayHeader } from "./event-day-header";
6
+ import type { EventCardLabels, EventFormatters, EventPreview } from "./event-types";
7
+
8
+ const labels: EventCardLabels = { free: "Free", liveNow: "Live now" };
9
+
10
+ const formatters: EventFormatters = {
11
+ time: (iso) => iso.slice(11, 16),
12
+ day: () => "Thu, Sep 3",
13
+ price: (amount) => `${amount} RON`,
14
+ };
15
+
16
+ const POSTER =
17
+ "https://images.unsplash.com/photo-1514525253161-7a46d19cd819?w=640&q=70&auto=format&fit=crop";
18
+
19
+ const event = (
20
+ id: string,
21
+ title: string,
22
+ place: string,
23
+ hour: string,
24
+ fee?: number,
25
+ poster = POSTER
26
+ ): EventPreview => ({
27
+ id,
28
+ title,
29
+ location: place,
30
+ place_name: place,
31
+ starts_at: `2026-09-03T${hour}:00`,
32
+ entry_fee: fee,
33
+ poster_url: poster,
34
+ });
35
+
36
+ const meta: Meta<typeof TimelineDay> = {
37
+ title: "Events/TimelineDay",
38
+ component: TimelineDay,
39
+ tags: ["autodocs"],
40
+ decorators: [
41
+ (Story) => (
42
+ <div className="mx-auto max-w-[1180px] bg-background px-4">
43
+ <Story />
44
+ </div>
45
+ ),
46
+ ],
47
+ parameters: {
48
+ layout: "fullscreen",
49
+ docs: {
50
+ description: {
51
+ component:
52
+ "One day of the timeline, ONE DOM for both viewports. Below `lg` it is a column of hours, each a `TimelineHour` with a gutter chip and the hour's cards. From `lg` the same nodes become lane │ spine │ content: the hour with `role=\"hero\"` is placed in the lane by the grid and pins, every other hour stations itself on the spine, and cards past the cap paint as rows (`EventCard density=\"row-lg\"`). Resize the canvas across 1024px to watch one tree change shape. `rows` counts the column-3 children.",
53
+ },
54
+ },
55
+ },
56
+ };
57
+
58
+ export default meta;
59
+ type Story = StoryObj<typeof TimelineDay>;
60
+
61
+ function Card(props: {
62
+ ev: EventPreview;
63
+ emphasis?: boolean | "lg";
64
+ hideWhen?: boolean | "below-lg";
65
+ density?: "card" | "row-lg";
66
+ liveStatus?: string;
67
+ priority?: boolean;
68
+ }) {
69
+ return (
70
+ <EventCard
71
+ event={props.ev}
72
+ labels={labels}
73
+ formatters={formatters}
74
+ emphasis={props.emphasis}
75
+ hideWhen={props.hideWhen ?? true}
76
+ density={props.density}
77
+ liveStatus={props.liveStatus}
78
+ showPrice
79
+ tags={["Live music", "Jazz"]}
80
+ href={`#${props.ev.id}`}
81
+ priority={props.priority}
82
+ />
83
+ );
84
+ }
85
+
86
+ /**
87
+ * A six-hour Friday with the hero in the middle: 19:00 opens, the 20:00 hero
88
+ * is lifted into the lane on desktop while its hour's other event stays as a
89
+ * continuation, later hours demote to rows past the cap, and a group of
90
+ * events the ingest never timed trails the day off the axis.
91
+ */
92
+ export const FullDay: Story = {
93
+ render: () => (
94
+ <div className="flex flex-col gap-3 py-6">
95
+ <EventDayHeader
96
+ dayNumber="03"
97
+ weekday="Thursday"
98
+ meta="September · 9 events · 1 live"
99
+ size="responsive"
100
+ />
101
+ <TimelineDay rows={6} laneTop="16px">
102
+ <TimelineHour time="19:00" countLabel="2 events">
103
+ <Card ev={event("a", "Vinyl night at the bar", "Joben Bistro", "19:00", 0)} priority />
104
+ <Card ev={event("b", "Pub quiz: nineties", "Klausen", "19:00", 20)} />
105
+ </TimelineHour>
106
+ <TimelineHour time="20:00" role="hero" isLive liveLabel="NOW ON" countLabel="1 event">
107
+ <Card
108
+ ev={event("c", "Subcarpați live — sold-out tour warm-up", "Form Space", "20:00", 60)}
109
+ emphasis="lg"
110
+ hideWhen="below-lg"
111
+ liveStatus="Live now · ends around 23:00"
112
+ />
113
+ </TimelineHour>
114
+ <TimelineHour time="20:00" role="continuation" isLive liveLabel="NOW ON" countLabel="1 event">
115
+ <Card ev={event("d", "Wine tasting: Dealu Mare", "Enoteca", "20:00", 90)} />
116
+ </TimelineHour>
117
+ <TimelineHour time="21:00" countLabel="3 events" layout="rows">
118
+ <Card ev={event("e", "Comedy open mic", "Flying Circus", "21:00", 25)} density="row-lg" />
119
+ <Card ev={event("f", "Techno: Berlin calling", "Mansion", "21:00", 40)} density="row-lg" />
120
+ <Card ev={event("g", "Salsa social", "Casa Tranzit", "21:00", 0)} density="row-lg" />
121
+ </TimelineHour>
122
+ <TimelineHour time="23:00" countLabel="1 event" layout="rows">
123
+ <Card ev={event("h", "Late-night jazz jam", "Jazz Club", "23:00")} density="row-lg" />
124
+ </TimelineHour>
125
+ <TimelineHour
126
+ time="Time to be confirmed"
127
+ role="unscheduled"
128
+ countLabel="1 event"
129
+ note="The venue has not published a start time."
130
+ >
131
+ <Card ev={event("i", "Board games afternoon", "Insomnia Café", "00:00", 0)} />
132
+ </TimelineHour>
133
+ </TimelineDay>
134
+ </div>
135
+ ),
136
+ };
137
+
138
+ /** A quiet Tuesday: one hour, one event, no hero — no lane on desktop either. */
139
+ export const OneHour: Story = {
140
+ render: () => (
141
+ <div className="flex flex-col gap-3 py-6">
142
+ <EventDayHeader dayNumber="08" weekday="Tuesday" meta="September · 1 event" size="responsive" />
143
+ <TimelineDay rows={1}>
144
+ <TimelineHour time="19:30" countLabel="1 event">
145
+ <Card ev={event("a", "Book club: Cărtărescu", "Cărturești", "19:30", 0)} priority />
146
+ </TimelineHour>
147
+ </TimelineDay>
148
+ </div>
149
+ ),
150
+ };
151
+
152
+ /**
153
+ * Two full days stacked, so the lane's pin and release can be watched at
154
+ * 1024 and 1440: scroll the canvas and the first day's poster holds until
155
+ * its column runs out, then the second day's takes over.
156
+ */
157
+ export const StickyLane: Story = {
158
+ render: () => (
159
+ <div className="flex flex-col gap-10 py-6">
160
+ {["03", "04"].map((day) => (
161
+ <div key={day} className="flex flex-col gap-3">
162
+ <EventDayHeader
163
+ dayNumber={day}
164
+ weekday={day === "03" ? "Thursday" : "Friday"}
165
+ meta="September · 7 events"
166
+ size="responsive"
167
+ />
168
+ <TimelineDay rows={4} laneTop="16px">
169
+ <TimelineHour time="18:00" role="hero" countLabel="1 event">
170
+ <Card
171
+ ev={event(`${day}-hero`, "Headliner of the night", "Form Space", "18:00", 50)}
172
+ emphasis="lg"
173
+ hideWhen="below-lg"
174
+ />
175
+ </TimelineHour>
176
+ {["19:00", "20:00", "21:00", "22:00"].map((hour) => (
177
+ <TimelineHour key={hour} time={hour} countLabel="2 events" layout="rows">
178
+ <Card
179
+ ev={event(`${day}-${hour}-1`, `Something at ${hour}`, "Klausen", hour, 20)}
180
+ density="row-lg"
181
+ />
182
+ <Card
183
+ ev={event(`${day}-${hour}-2`, `Something else at ${hour}`, "Joben", hour, 0)}
184
+ density="row-lg"
185
+ />
186
+ </TimelineHour>
187
+ ))}
188
+ </TimelineDay>
189
+ </div>
190
+ ))}
191
+ </div>
192
+ ),
193
+ };
194
+
195
+ export const Skeleton: Story = {
196
+ render: () => (
197
+ <div className="py-6">
198
+ <TimelineDaySkeleton hourCount={3} />
199
+ </div>
200
+ ),
201
+ };
@@ -0,0 +1,78 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { TimelineDay } from "./timeline-day";
4
+ import { TimelineHour } from "./timeline-hour";
5
+
6
+ describe("TimelineDay", () => {
7
+ it("renders every hour in source order, hero included, with one puck per stationed hour", () => {
8
+ const { container } = render(
9
+ <TimelineDay rows={2}>
10
+ <TimelineHour time="19:00" countLabel="1 event">
11
+ <p>Early</p>
12
+ </TimelineHour>
13
+ <TimelineHour time="20:00" role="hero">
14
+ <p>Hero</p>
15
+ </TimelineHour>
16
+ <TimelineHour time="21:00" countLabel="1 event">
17
+ <p>Late</p>
18
+ </TimelineHour>
19
+ </TimelineDay>
20
+ );
21
+
22
+ const hours = [...container.querySelectorAll("[data-timeline-hour]")];
23
+ expect(hours.map((hour) => hour.getAttribute("data-timeline-hour"))).toEqual([
24
+ "hour",
25
+ "hero",
26
+ "hour",
27
+ ]);
28
+ // Chronological in the DOM — the phone's visual order and the reading order.
29
+ expect(container.textContent).toMatch(/19:00[^]*Early[^]*20:00[^]*Hero[^]*21:00[^]*Late/);
30
+ expect(screen.getAllByText(/^\d\d:00$/)).toHaveLength(3);
31
+ });
32
+
33
+ it("sets the row count the lane and spine span", () => {
34
+ const { container } = render(
35
+ <TimelineDay rows={5} laneTop="120px">
36
+ <TimelineHour time="19:00">
37
+ <p>x</p>
38
+ </TimelineHour>
39
+ </TimelineDay>
40
+ );
41
+ const root = container.firstElementChild as HTMLElement;
42
+ expect(root.style.getPropertyValue("--timeline-rows")).toBe("5");
43
+ expect(root.style.getPropertyValue("--timeline-lane-top")).toBe("120px");
44
+ });
45
+
46
+ it("gives an unscheduled group an inline heading and no station", () => {
47
+ const { container } = render(
48
+ <TimelineDay rows={1}>
49
+ <TimelineHour time="Time to be confirmed" role="unscheduled" countLabel="2 events" note="No hour">
50
+ <p>a</p>
51
+ <p>b</p>
52
+ </TimelineHour>
53
+ </TimelineDay>
54
+ );
55
+ expect(screen.getByText("Time to be confirmed")).toBeInTheDocument();
56
+ expect(screen.getByText("2 events")).toBeInTheDocument();
57
+ expect(screen.getByText("No hour")).toBeInTheDocument();
58
+ expect(container.querySelector(".font-mono.tabular-nums.whitespace-nowrap")).toBeNull();
59
+ });
60
+
61
+ it("rules between rows but not between cards", () => {
62
+ const { container: rows } = render(
63
+ <TimelineHour time="21:00" layout="rows">
64
+ <p>a</p>
65
+ <p>b</p>
66
+ </TimelineHour>
67
+ );
68
+ const { container: cards } = render(
69
+ <TimelineHour time="21:00" layout="cards">
70
+ <p>a</p>
71
+ <p>b</p>
72
+ </TimelineHour>
73
+ );
74
+ // The hour's own hairline (vertical) plus the heading rule plus one between the two rows.
75
+ expect(rows.querySelectorAll('[data-orientation="horizontal"]').length).toBe(2);
76
+ expect(cards.querySelectorAll('[data-orientation="horizontal"]').length).toBe(1);
77
+ });
78
+ });
@@ -0,0 +1,114 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cn } from "../lib/utils";
5
+ import { Separator } from "./separator";
6
+ import { Skeleton } from "./skeleton";
7
+ import { TimelineHourSkeleton } from "./timeline-hour";
8
+
9
+ /**
10
+ * TimelineDay — one day's programme on the timeline: a column of
11
+ * `TimelineHour`s that becomes, from `lg`, the three-column row
12
+ * lane │ spine │ content that `TimelineBand` used to draw.
13
+ *
14
+ * ONE DOM FOR BOTH VIEWPORTS. `TimelineBand` took the lane as a prop — a
15
+ * second parent — which forced the consumer to render two trees and pick one
16
+ * in JavaScript, and a server render cannot pick. Here the lane is grid
17
+ * column 1, and the day's hero is an ordinary child in its chronological
18
+ * slot that the grid PLACES in that column from `lg` (see `TimelineHour`
19
+ * `role="hero"`). Source order is the phone's visual order, which is the
20
+ * primary surface and the one a screen reader gets on both.
21
+ *
22
+ * `rows` is the number of children that are NOT the hero — the column-3
23
+ * items. It sets `--timeline-rows`, which both the lane and the spine span,
24
+ * and the explicit row track count. It has to be a number the caller
25
+ * supplies: the grid needs the lane to cover every row so auto-placement
26
+ * never drops a later hour into the lane column, and this component cannot
27
+ * tell a hero child from an hour child without inspecting props.
28
+ *
29
+ * `laneTop` is the lane's sticky `top` as a CSS value. `position: sticky` on
30
+ * a grid item is bounded by its grid area — column 1, rows 1…N, the day's
31
+ * full height — so the poster holds while column 3 scrolls and releases when
32
+ * the day runs out, with no scroll listener and no measured height.
33
+ *
34
+ * The spine is a vertical `Separator` in column 2 spanning every row, drawn
35
+ * here so consumers never forget it; the pucks that sit on it are
36
+ * `TimelineHour`'s.
37
+ */
38
+
39
+ export type TimelineDayProps = {
40
+ /** The column-3 children count — every child except the hero. */
41
+ rows: number;
42
+ /** Sticky `top` for the lane, as a CSS value. Omitted, the lane scrolls. */
43
+ laneTop?: string;
44
+ /** `TimelineHour`s, in chronological order, the hero among them. */
45
+ children: React.ReactNode;
46
+ className?: string;
47
+ };
48
+
49
+ export function TimelineDay({ rows, laneTop, children, className }: TimelineDayProps) {
50
+ const style = {
51
+ "--timeline-rows": String(Math.max(rows, 1)),
52
+ ...(laneTop !== undefined ? { "--timeline-lane-top": laneTop } : {}),
53
+ } as React.CSSProperties;
54
+
55
+ return (
56
+ <div
57
+ className={cn(
58
+ "flex flex-col",
59
+ "lg:grid lg:grid-cols-[var(--timeline-lane)_var(--timeline-spine)_minmax(0,1fr)]",
60
+ "lg:[grid-template-rows:repeat(var(--timeline-rows),auto)]",
61
+ className
62
+ )}
63
+ style={style}
64
+ >
65
+ <div
66
+ aria-hidden
67
+ className={cn(
68
+ "hidden lg:col-start-2 lg:row-start-1 lg:flex lg:justify-center lg:self-stretch",
69
+ "lg:[grid-row-end:span_var(--timeline-rows)]"
70
+ )}
71
+ >
72
+ <Separator orientation="vertical" className="h-full" />
73
+ </div>
74
+ {children}
75
+ </div>
76
+ );
77
+ }
78
+
79
+ export type TimelineDaySkeletonProps = {
80
+ /** Hours to reserve space for. */
81
+ hourCount?: number;
82
+ className?: string;
83
+ };
84
+
85
+ /**
86
+ * Holds the day's geometry at both widths so nothing jumps when data lands:
87
+ * the phone's stacked hours, the desktop's lane / spine / grid.
88
+ */
89
+ export function TimelineDaySkeleton({ hourCount = 2, className }: TimelineDaySkeletonProps) {
90
+ return (
91
+ <div
92
+ aria-hidden="true"
93
+ className={cn(
94
+ "flex flex-col",
95
+ "lg:grid lg:grid-cols-[var(--timeline-lane)_var(--timeline-spine)_minmax(0,1fr)]",
96
+ "lg:[grid-template-rows:repeat(var(--timeline-rows),auto)]",
97
+ className
98
+ )}
99
+ style={{ "--timeline-rows": String(hourCount) } as React.CSSProperties}
100
+ >
101
+ <div className="hidden lg:col-start-1 lg:row-start-1 lg:flex lg:flex-col lg:gap-3 lg:pt-3.5 lg:[grid-row-end:span_var(--timeline-rows)]">
102
+ <Skeleton className="aspect-[3/4] w-full rounded-xl" />
103
+ <Skeleton className="h-[18px] w-[70%]" />
104
+ <Skeleton className="h-[13px] w-[45%]" />
105
+ </div>
106
+ <div className="hidden lg:col-start-2 lg:row-start-1 lg:flex lg:justify-center lg:self-stretch lg:[grid-row-end:span_var(--timeline-rows)]">
107
+ <Separator orientation="vertical" className="h-full" />
108
+ </div>
109
+ {Array.from({ length: hourCount }, (_, index) => (
110
+ <TimelineHourSkeleton key={index} cardCount={index === 0 ? 3 : 2} />
111
+ ))}
112
+ </div>
113
+ );
114
+ }
@@ -0,0 +1,142 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Radio } from "lucide-react";
5
+ import { cn } from "../lib/utils";
6
+
7
+ /**
8
+ * Ring pulse on the live puck — inherited from an earlier `TimelinePlayhead`,
9
+ * which carried this surface's only motion. Kept at its 2.4s `ease-in-out` and
10
+ * its reduced-motion kill switch, but BOX-SHADOW ONLY: a `transform: scale`
11
+ * would make the hairline visibly breathe here, because this puck sits ON the
12
+ * rail. (Today's `timeline-playhead.tsx` is static and sits between bands.)
13
+ */
14
+ const LIVE_PUCK_PULSE_CLASS = "bm-timeline-live-puck-pulse";
15
+
16
+ /**
17
+ * The phone axis gutter, shared by `TimelineHour` and `TimelinePlayhead` so
18
+ * the hour chips and the current-time label sit in one column. 58px is the
19
+ * width of a five-glyph 24-hour label ("20:00") in the chip's mono type plus
20
+ * its padding; a 12-hour locale's "09:00 AM" overflows it, which is one of the
21
+ * reasons the events surfaces format 24-hour everywhere.
22
+ */
23
+ export const TIMELINE_MOBILE_GUTTER_CLASS = "w-[58px]";
24
+
25
+ export type TimelineHourPuckProps = {
26
+ /** The hour, already formatted and localised — or an em dash for a quiet day. */
27
+ time: string;
28
+ /** Marks this as the hour currently in progress. */
29
+ isLive?: boolean;
30
+ /** Shown under the puck when live, e.g. "NOW ON". Required to render it. */
31
+ liveLabel?: string;
32
+ /**
33
+ * `station`: the bordered puck that sits ON the hairline, for the desktop
34
+ * spine. `chip`: a filled, fixed-width lozenge with the hairline starting
35
+ * BELOW it, for the phone gutter — an app's time label, not a station on a
36
+ * rail. `responsive` (the default): the chip below `lg`, the station from
37
+ * `lg` — ONE node that changes its look with the viewport, so a
38
+ * server-rendered timeline carries a single DOM for both widths. In the
39
+ * chip look the live label is visually hidden: the tinted chip plus a small
40
+ * on-air glyph are the visible signal (the glyph so the state never rests on
41
+ * colour alone once the pulse is reduced away) and the label stays for
42
+ * screen readers only.
43
+ */
44
+ variant?: "station" | "chip" | "responsive";
45
+ className?: string;
46
+ };
47
+
48
+ /**
49
+ * The station on the timeline's axis — ONE object, positioned by its host
50
+ * (`TimelineHour`): pinned to the top of the phone gutter, centred on the
51
+ * hour's heading rule out on the desktop spine.
52
+ *
53
+ * The `bg-background` fill punches through the hairline so the rail reads as
54
+ * continuous behind the label rather than butting into it.
55
+ *
56
+ * The live label is absolutely positioned rather than a flex sibling: hosts
57
+ * centre this puck with `-translate-y-1/2`, and a two-item column would centre
58
+ * the STACK, lifting the puck off the line it exists to sit on.
59
+ */
60
+ export function TimelineHourPuck({
61
+ time,
62
+ isLive,
63
+ liveLabel,
64
+ variant = "responsive",
65
+ className,
66
+ }: TimelineHourPuckProps) {
67
+ const chipClasses = cn(
68
+ "inline-flex h-6 min-w-[58px] items-center justify-center gap-1 rounded-[7px] border border-transparent px-1 text-[12px] font-medium",
69
+ isLive ? cn("bg-success/10 text-success", LIVE_PUCK_PULSE_CLASS) : "bg-muted text-foreground"
70
+ );
71
+ const stationClasses = cn(
72
+ "rounded-[7px] border bg-background px-[7px] py-[3px] text-[12.5px] font-medium",
73
+ isLive ? cn("border-success/40 text-success", LIVE_PUCK_PULSE_CLASS) : "border-border text-foreground"
74
+ );
75
+ const glyphClasses = "h-2.5 w-2.5 shrink-0";
76
+ const labelHiddenClasses = "sr-only";
77
+ const labelStationClasses = cn(
78
+ "absolute top-[calc(100%+6px)] whitespace-nowrap bg-background px-1",
79
+ "text-[9.5px] font-extrabold tracking-[0.08em] text-success"
80
+ );
81
+ // `sr-only` spelled out property by property so every one has an `lg:`
82
+ // twin — this project's Tailwind emits no `max-*` queries, and `lg:not-sr-only`
83
+ // would reset `position` under the `absolute` the station needs.
84
+ const labelResponsiveClasses = cn(
85
+ "absolute -m-px h-px w-px overflow-hidden whitespace-nowrap p-0 [clip:rect(0,0,0,0)]",
86
+ "lg:m-0 lg:h-auto lg:w-auto lg:overflow-visible lg:px-1 lg:[clip:auto]",
87
+ "lg:top-[calc(100%+6px)] lg:bg-background lg:text-[9.5px] lg:font-extrabold lg:tracking-[0.08em] lg:text-success"
88
+ );
89
+
90
+ return (
91
+ <span className={cn("relative flex justify-center", className)}>
92
+ {isLive && (
93
+ <style>{`
94
+ @keyframes ${LIVE_PUCK_PULSE_CLASS} {
95
+ 0%, 100% { box-shadow: 0 0 0 0 color-mix(in oklch, var(--success) 40%, transparent); }
96
+ 50% { box-shadow: 0 0 0 4px color-mix(in oklch, var(--success) 0%, transparent); }
97
+ }
98
+ .${LIVE_PUCK_PULSE_CLASS} {
99
+ animation: ${LIVE_PUCK_PULSE_CLASS} 2.4s ease-in-out infinite;
100
+ }
101
+ @media (prefers-reduced-motion: reduce) {
102
+ .${LIVE_PUCK_PULSE_CLASS} { animation: none; }
103
+ }
104
+ `}</style>
105
+ )}
106
+ <span
107
+ className={cn(
108
+ "whitespace-nowrap font-mono tabular-nums",
109
+ variant === "chip" && chipClasses,
110
+ variant === "station" && stationClasses,
111
+ variant === "responsive" &&
112
+ cn(
113
+ chipClasses,
114
+ // The station look from `lg`: bordered, background-filled,
115
+ // content-width. Every chip utility above has an `lg:` twin.
116
+ // `min-w-max`, NOT `min-w-0`: the host centres the puck inside
117
+ // a zero-width flex box, and a flex item allowed to shrink to 0
118
+ // collapses to a 24px square behind its own text.
119
+ "lg:h-auto lg:min-w-max lg:shrink-0 lg:gap-0 lg:border-border lg:bg-background lg:px-[7px] lg:py-[3px] lg:text-[12.5px] lg:text-foreground",
120
+ isLive && "lg:border-success/40 lg:bg-background lg:text-success"
121
+ )
122
+ )}
123
+ >
124
+ {isLive && variant !== "station" && (
125
+ <Radio className={cn(glyphClasses, variant === "responsive" && "lg:hidden")} aria-hidden />
126
+ )}
127
+ {time}
128
+ </span>
129
+ {isLive && !!liveLabel && (
130
+ <span
131
+ className={cn(
132
+ variant === "chip" && labelHiddenClasses,
133
+ variant === "station" && labelStationClasses,
134
+ variant === "responsive" && labelResponsiveClasses
135
+ )}
136
+ >
137
+ {liveLabel}
138
+ </span>
139
+ )}
140
+ </span>
141
+ );
142
+ }