@bearmenu/ui 0.8.7 → 0.8.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/dist/{chunk-637L7XQ3.js → chunk-3TNPSN4D.js} +28 -9
  2. package/dist/chunk-AHL45DD2.js +145 -0
  3. package/dist/chunk-IYDH5IU6.js +66 -0
  4. package/dist/chunk-OPVKL4VS.js +18 -0
  5. package/dist/components/alert.d.ts +2 -2
  6. package/dist/components/badge.d.ts +1 -1
  7. package/dist/components/category-deck.js +10 -1
  8. package/dist/components/coverflow.d.ts +11 -3
  9. package/dist/components/coverflow.js +34 -51
  10. package/dist/components/date-input.d.ts +1 -1
  11. package/dist/components/drift-rail.d.ts +24 -10
  12. package/dist/components/drift-rail.js +1 -1
  13. package/dist/components/event-card.d.ts +8 -0
  14. package/dist/components/event-card.js +63 -33
  15. package/dist/components/event-compact-row.d.ts +32 -5
  16. package/dist/components/event-compact-row.js +119 -38
  17. package/dist/components/event-day-header.js +3 -1
  18. package/dist/components/event-day-separator.d.ts +7 -1
  19. package/dist/components/event-day-separator.js +3 -2
  20. package/dist/components/event-hub-hero.d.ts +25 -4
  21. package/dist/components/event-hub-hero.js +89 -19
  22. package/dist/components/event-list-row.d.ts +19 -6
  23. package/dist/components/event-list-row.js +78 -34
  24. package/dist/components/hour-band.d.ts +38 -40
  25. package/dist/components/hour-band.js +55 -27
  26. package/dist/components/hour-group.js +1 -1
  27. package/dist/components/poster-tape.js +1 -1
  28. package/dist/components/poster-wall.d.ts +3 -3
  29. package/dist/components/poster-wall.js +73 -17
  30. package/dist/components/section-header.d.ts +16 -1
  31. package/dist/components/section-header.js +26 -7
  32. package/dist/components/snap-rail.d.ts +34 -0
  33. package/dist/components/snap-rail.js +51 -0
  34. package/dist/components/tag.d.ts +2 -2
  35. package/dist/components/text.d.ts +1 -1
  36. package/dist/components/timeline-band.d.ts +20 -2
  37. package/dist/components/timeline-band.js +1 -1
  38. package/dist/components/timeline-playhead.d.ts +26 -0
  39. package/dist/components/timeline-playhead.js +25 -0
  40. package/dist/components/weekend-panel.d.ts +5 -4
  41. package/dist/components/weekend-panel.js +85 -71
  42. package/dist/hooks/use-drag-swipe.d.ts +44 -0
  43. package/dist/hooks/use-drag-swipe.js +2 -0
  44. package/dist/hooks/use-media-query.d.ts +17 -0
  45. package/dist/hooks/use-media-query.js +2 -0
  46. package/package.json +1 -1
  47. package/src/components/category-deck.tsx +19 -8
  48. package/src/components/coverflow.test.tsx +28 -1
  49. package/src/components/coverflow.tsx +37 -43
  50. package/src/components/drift-rail.tsx +167 -42
  51. package/src/components/event-card.tsx +55 -10
  52. package/src/components/event-compact-row.stories.tsx +30 -0
  53. package/src/components/event-compact-row.test.tsx +49 -0
  54. package/src/components/event-compact-row.tsx +161 -37
  55. package/src/components/event-day-header.tsx +3 -1
  56. package/src/components/event-day-separator.tsx +8 -1
  57. package/src/components/event-hub-hero.stories.tsx +30 -0
  58. package/src/components/event-hub-hero.tsx +102 -30
  59. package/src/components/event-list-row.stories.tsx +34 -0
  60. package/src/components/event-list-row.test.tsx +53 -0
  61. package/src/components/event-list-row.tsx +135 -87
  62. package/src/components/hour-band.stories.tsx +28 -5
  63. package/src/components/hour-band.tsx +90 -70
  64. package/src/components/poster-wall.test.tsx +28 -0
  65. package/src/components/poster-wall.tsx +96 -23
  66. package/src/components/section-header.stories.tsx +24 -0
  67. package/src/components/section-header.test.tsx +28 -0
  68. package/src/components/section-header.tsx +45 -7
  69. package/src/components/snap-rail.stories.tsx +79 -0
  70. package/src/components/snap-rail.test.tsx +18 -0
  71. package/src/components/snap-rail.tsx +67 -0
  72. package/src/components/timeline-band.tsx +52 -12
  73. package/src/components/timeline-playhead.stories.tsx +30 -0
  74. package/src/components/timeline-playhead.test.tsx +11 -0
  75. package/src/components/timeline-playhead.tsx +46 -0
  76. package/src/components/weekend-panel.test.tsx +43 -0
  77. package/src/components/weekend-panel.tsx +142 -108
  78. package/src/hooks/use-drag-swipe.test.tsx +66 -0
  79. package/src/hooks/use-drag-swipe.ts +105 -0
  80. package/src/hooks/use-media-query.ts +31 -0
  81. package/dist/chunk-GPH6L4DX.js +0 -55
@@ -4,41 +4,39 @@ import * as React from "react";
4
4
  import { cn } from "../lib/utils";
5
5
  import { Separator } from "./separator";
6
6
  import { Skeleton } from "./skeleton";
7
- import { TimelineHourPuck } from "./timeline-band";
7
+ import {
8
+ TIMELINE_MOBILE_GUTTER_CLASS,
9
+ TimelineHourPuck,
10
+ } from "./timeline-band";
8
11
 
9
12
  /**
10
13
  * HourBand — one hour on the timeline's axis, with that hour's events beside it.
11
14
  *
12
- * The design's mobile timeline is deliberately plain: a 52px gutter carrying a
13
- * hairline and an hour puck, and full-width cards to its right. Nothing else.
14
- * The cards inside carry no timestamp of their own this band IS the
15
- * timestamp, which is why `EventCard` has `hideWhen`.
15
+ * The mobile timeline is an app list, not a page: a 58px gutter carrying a
16
+ * filled hour chip with a hairline dropping from beneath it, and the hour's
17
+ * rows to the right, separated by hairlines of their own. The rows carry no
18
+ * timestamp — this band IS the timestamp, which is why `EventCard` has
19
+ * `hideWhen` and `EventCompactRow` has `hideTime`.
16
20
  *
17
- * The puck is the shared `TimelineHourPuck`, sitting ON the line with a
18
- * background fill punching through it so the hairline reads as continuous
19
- * behind the label. It used to be a second, hand-rolled copy of the desktop
20
- * band's puck, and the two had drifted apart this one borderless and
21
- * `rounded-md px-1.5`, the desktop one bordered and `rounded-[7px] px-[7px]`.
22
- * Sharing the object changes this gutter's pixels: the mobile puck now carries
23
- * a border, which is what makes it read as clipped onto the rail rather than
24
- * text floating over it, and matches the station on the desktop spine.
21
+ * The chip is the shared `TimelineHourPuck` in its `chip` variant, so the
22
+ * desktop spine's station and this gutter's label are one object with two
23
+ * looks rather than two objects that drift. The hairline starts BELOW the chip
24
+ * rather than running behind it: a chip is a label at the top of its hour, a
25
+ * station is a tick on a continuous rail, and the two read differently.
25
26
  *
26
- * `isLive` turns the puck green-bordered and reveals the NOW label beneath it,
27
- * which is why live bands need extra top padding on the content column the
28
- * label occupies space the cards would otherwise start in.
27
+ * `isLive` tints the chip and the hairline with `--success`; the live label is
28
+ * kept for screen readers (the chip variant hides it visually). The playhead
29
+ * the exact current time is a separate `TimelinePlayhead` rendered between
30
+ * bands by the caller, because an hour spans sixty minutes and "now" is one of
31
+ * them.
29
32
  *
30
- * ── Why the gap is 24px and not 12px ──────────────────────────────────────
31
- * THE PUCK IS WIDER THAN THE GUTTER IT SITS IN, and the gap is what absorbs
32
- * the difference. A 12-hour locale renders "09:00 AM" 8 monospace glyphs at
33
- * 12.5px (~60px) plus `px-[7px]` twice and a border twice, so ~76px of puck
34
- * centred on a 52px gutter, overflowing ~12px on each side. At the original
35
- * `gap-3` that right-hand overflow exactly consumed the gap and the puck's
36
- * edge landed ON the content column, which read as the hairline colliding
37
- * with the rows beside it.
38
- *
39
- * 24px leaves ~12px of real clearance at the widest label. Do not tighten it
40
- * back without re-deriving against a 12-hour locale: a 24-hour one ("09:00")
41
- * is ~30px narrower and hides the problem completely.
33
+ * ROWS ARE SEPARATED HERE, NOT IN THE ROW. `EventCompactRow` deliberately
34
+ * draws no divider (its container does see its docblock), so this band wraps
35
+ * each child and rules between them. The wrapper, not the child, carries the
36
+ * rule, so the row's own negative-margin hover wash is untouched. Children are
37
+ * read with `Children.toArray`, which drops `null`/`false` so a conditional
38
+ * first row never inherits a rule meant for a second; pass rows as siblings,
39
+ * not inside a fragment a fragment is one child and gets one wrapper.
42
40
  */
43
41
 
44
42
  export type HourBandProps = {
@@ -46,80 +44,102 @@ export type HourBandProps = {
46
44
  time: string;
47
45
  /** Marks this as the hour currently in progress. */
48
46
  isLive?: boolean;
49
- /** Shown under the puck when live, e.g. "NOW ON". Required to render it. */
47
+ /** Screen-reader text for the live hour, e.g. "Now on". Required to render it. */
50
48
  liveLabel?: string;
51
- /** The hour's events. */
49
+ /** The hour's events. Each direct child is one row or card. */
52
50
  children: React.ReactNode;
51
+ /**
52
+ * `rows` (default): compact rows, ruled between with a Separator. `cards`:
53
+ * poster cards, stacked with a 16px gap and no rules — a card is its own
54
+ * frame, and a hairline between two posters reads as a third object.
55
+ */
56
+ layout?: "rows" | "cards";
53
57
  className?: string;
54
58
  };
55
59
 
56
- export function HourBand({ time, isLive, liveLabel, children, className }: HourBandProps) {
57
- const showLiveLabel = isLive && !!liveLabel;
58
-
60
+ export function HourBand({
61
+ time,
62
+ isLive,
63
+ liveLabel,
64
+ children,
65
+ layout = "rows",
66
+ className,
67
+ }: HourBandProps) {
68
+ const isCards = layout === "cards";
59
69
  return (
60
- <div className={cn("flex gap-6 pt-[18px]", className)}>
61
- <div className="relative flex w-[52px] shrink-0 justify-center">
62
- <Separator orientation="vertical" className="h-full" />
70
+ <div className={cn("flex gap-3 pt-2.5", className)}>
71
+ <div className={cn("relative shrink-0", TIMELINE_MOBILE_GUTTER_CLASS)}>
63
72
  <TimelineHourPuck
64
73
  time={time}
65
74
  isLive={isLive}
66
75
  liveLabel={liveLabel}
67
- className="absolute left-1/2 top-0 w-0"
76
+ variant="chip"
77
+ />
78
+ <Separator
79
+ orientation="vertical"
80
+ className={cn(
81
+ "absolute bottom-[-10px] left-1/2 top-[30px] h-auto -translate-x-1/2",
82
+ isLive && "bg-success/30",
83
+ )}
68
84
  />
69
85
  </div>
70
86
 
71
87
  <div
72
- className={cn(
73
- "flex min-w-0 flex-1 flex-col gap-5 pb-2",
74
- showLiveLabel ? "pt-[34px]" : "pt-1"
75
- )}
88
+ className={cn("flex min-w-0 flex-1 flex-col", isCards && "gap-4 pb-2")}
76
89
  >
77
- {children}
90
+ {React.Children.toArray(children).map((child, index) => (
91
+ <div key={index}>
92
+ {!isCards && index > 0 && <Separator />}
93
+ {child}
94
+ </div>
95
+ ))}
78
96
  </div>
79
97
  </div>
80
98
  );
81
99
  }
82
100
 
83
101
  export type HourBandSkeletonProps = {
84
- /** Cards to reserve space for. The design's loading frame shows two. */
102
+ /** Rows to reserve space for. */
85
103
  cardCount?: number;
86
104
  className?: string;
87
105
  };
88
106
 
89
107
  /**
90
- * The mobile timeline's loading state, mirroring `HourBand` exactly — same 52px
91
- * gutter, same hairline, same content gap — so nothing jumps when data lands.
108
+ * The mobile timeline's loading state, mirroring `HourBand` exactly — same
109
+ * gutter, same hairline, same row rhythm — so nothing jumps when data lands.
92
110
  *
93
- * The puck is omitted rather than blocked out: the hour is not known until the
94
- * data arrives, and a grey lozenge on the axis reads as a real, unreadable
95
- * timestamp. The hairline alone still says "this is a time axis".
96
- *
97
- * Card footprints come from the design's own loading frame: a 220px poster with
98
- * an 16px title bar and a 12px meta bar, then a 170px poster with a 15px title.
111
+ * The chip is blocked out at its real size rather than omitted: it is a fixed
112
+ * 58x24 lozenge, so a grey one reads as "a time goes here" without inventing
113
+ * a time. Row footprints mirror `EventCompactRowSkeleton`.
99
114
  */
100
- export function HourBandSkeleton({ cardCount = 2, className }: HourBandSkeletonProps) {
101
- const cards = [
102
- { poster: 220, title: "80%", meta: "50%" },
103
- { poster: 170, title: "70%", meta: null },
104
- ];
105
-
115
+ export function HourBandSkeleton({
116
+ cardCount = 2,
117
+ className,
118
+ }: HourBandSkeletonProps) {
106
119
  return (
107
- <div className={cn("flex gap-6 pt-[18px]", className)} aria-hidden="true">
108
- <div className="relative flex w-[52px] shrink-0 justify-center">
109
- <Separator orientation="vertical" className="h-full" />
120
+ <div className={cn("flex gap-3 pt-2.5", className)} aria-hidden="true">
121
+ <div className={cn("relative shrink-0", TIMELINE_MOBILE_GUTTER_CLASS)}>
122
+ <Skeleton className="h-6 w-[58px] rounded-[7px]" />
123
+ <Separator
124
+ orientation="vertical"
125
+ className="absolute bottom-[-10px] left-1/2 top-[30px] h-auto -translate-x-1/2"
126
+ />
110
127
  </div>
111
128
 
112
- <div className="flex min-w-0 flex-1 flex-col gap-5 pb-2 pt-1">
113
- {Array.from({ length: cardCount }).map((_, index) => {
114
- const card = cards[index % cards.length];
115
- return (
116
- <div key={index} className="flex flex-col gap-2.5">
117
- <Skeleton className="w-full" style={{ height: card.poster }} />
118
- <Skeleton className="h-4" style={{ width: card.title }} />
119
- {card.meta && <Skeleton className="h-3" style={{ width: card.meta }} />}
129
+ <div className="flex min-w-0 flex-1 flex-col">
130
+ {Array.from({ length: cardCount }).map((_, index) => (
131
+ <div key={index}>
132
+ {index > 0 && <Separator />}
133
+ <div className="flex gap-3 py-[11px]">
134
+ <Skeleton className="aspect-[4/5] w-16 shrink-0 rounded-[10px]" />
135
+ <div className="min-w-0 flex-1">
136
+ <Skeleton className="h-[14px] w-3/4" />
137
+ <Skeleton className="mt-1.5 h-[11.5px] w-1/2" />
138
+ <Skeleton className="mt-2 h-[22px] w-16 rounded-full" />
139
+ </div>
120
140
  </div>
121
- );
122
- })}
141
+ </div>
142
+ ))}
123
143
  </div>
124
144
  </div>
125
145
  );
@@ -0,0 +1,28 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render } from "@testing-library/react";
3
+ import { PosterWall } from "./poster-wall";
4
+
5
+ const grid = (container: HTMLElement) =>
6
+ container.querySelector<HTMLElement>("[aria-hidden].grid")!;
7
+
8
+ describe("PosterWall grid", () => {
9
+ it("sizes the grid to the tiles and completes the last row by cycling them", () => {
10
+ const tiles = Array.from({ length: 5 }, (_, i) => ({ src: `p${i}.jpg` }));
11
+ const { container } = render(
12
+ <PosterWall tiles={tiles} variant="compact" />,
13
+ );
14
+ const g = grid(container);
15
+ // compact: width 390, height 330 → cols = round(√(5·1.18)) = 2, rows = 3 → 6 slots
16
+ expect(g.style.gridTemplateColumns).toBe("repeat(2, minmax(0, 1fr))");
17
+ expect(g.querySelectorAll("img")).toHaveLength(6);
18
+ expect(g.querySelectorAll("img")[5].getAttribute("src")).toBe("p0.jpg");
19
+ });
20
+
21
+ it("keeps the placeholder grid when there are no pictures", () => {
22
+ const { container } = render(<PosterWall tiles={[]} variant="compact" />);
23
+ const g = grid(container);
24
+ expect(g.style.gridTemplateColumns).toBe("repeat(6, minmax(0, 1fr))");
25
+ expect(g.children).toHaveLength(36);
26
+ expect(g.querySelectorAll("img")).toHaveLength(0);
27
+ });
28
+ });
@@ -17,6 +17,14 @@ import { Skeleton } from "./skeleton";
17
17
  * the container's size, alignment and padding for the given `variant`. All
18
18
  * overlay typography belongs to the caller.
19
19
  *
20
+ * THE GRID FOLLOWS THE TILES. It used to be a fixed 12×4 (48) or 6×6 (36)
21
+ * grid, and a city with 28 posters left twenty empty cells — a dark hole down
22
+ * one side of the masthead. Now the column count comes from the tile count
23
+ * and the wall's measured aspect ratio (`cols ≈ √(n · w/h)`, so tiles stay
24
+ * roughly square), rows follow, and the last row is completed by cycling the
25
+ * tiles. Fewer pictures means fewer, bigger tiles; never a gap. The default
26
+ * grid is kept for the server render and for an empty tile list.
27
+ *
20
28
  * The sweep is a genuine `@keyframes` animation (not Framer Motion) because
21
29
  * its exact timing is part of the design spec and it needs to keep running
22
30
  * indefinitely at zero React cost — the keyframes are scoped to this file via
@@ -43,16 +51,22 @@ function usePrefersReducedMotion(): boolean {
43
51
  return reduced;
44
52
  }
45
53
 
46
- function useInViewport<T extends Element>(): [React.RefObject<T | null>, boolean] {
54
+ function useInViewport<T extends Element>(): [
55
+ React.RefObject<T | null>,
56
+ boolean,
57
+ ] {
47
58
  const ref = React.useRef<T | null>(null);
48
59
  const [inView, setInView] = React.useState(true);
49
60
 
50
61
  React.useEffect(() => {
51
62
  const node = ref.current;
52
63
  if (!node || typeof IntersectionObserver === "undefined") return;
53
- const observer = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting), {
54
- threshold: 0,
55
- });
64
+ const observer = new IntersectionObserver(
65
+ ([entry]) => setInView(entry.isIntersecting),
66
+ {
67
+ threshold: 0,
68
+ },
69
+ );
56
70
  observer.observe(node);
57
71
  return () => observer.disconnect();
58
72
  }, []);
@@ -65,7 +79,7 @@ export type PosterWallTile = {
65
79
  };
66
80
 
67
81
  export type PosterWallProps = {
68
- /** Background tiles. Desktop renders 48, `compact` renders 36 shorter arrays are padded with placeholders. */
82
+ /** Background tiles. The grid sizes itself to how many there are (see above); with none, a placeholder grid renders. */
69
83
  tiles?: PosterWallTile[];
70
84
  /** `compact` is the bottom-aligned mobile treatment (fewer, larger tiles, a top-fading veil). */
71
85
  variant?: "default" | "compact";
@@ -74,22 +88,64 @@ export type PosterWallProps = {
74
88
  className?: string;
75
89
  };
76
90
 
77
- export function PosterWall({ tiles, variant = "default", children, className }: PosterWallProps) {
91
+ export function PosterWall({
92
+ tiles,
93
+ variant = "default",
94
+ children,
95
+ className,
96
+ }: PosterWallProps) {
78
97
  const isCompact = variant === "compact";
79
- const tileCount = isCompact ? POSTER_WALL_COMPACT_TILE_COUNT : POSTER_WALL_DESKTOP_TILE_COUNT;
80
- const slots = Array.from({ length: tileCount }, (_, index): PosterWallTile => tiles?.[index] ?? {});
98
+ const maxCols = isCompact ? 6 : 12;
99
+ const maxSlots = isCompact
100
+ ? POSTER_WALL_COMPACT_TILE_COUNT
101
+ : POSTER_WALL_DESKTOP_TILE_COUNT;
102
+ const heightPx = isCompact ? 330 : 420;
103
+ const pictures = React.useMemo(
104
+ () => (tiles ?? []).filter((tile) => !!tile.src),
105
+ [tiles],
106
+ );
81
107
 
82
108
  const prefersReducedMotion = usePrefersReducedMotion();
83
109
  const [wallRef, inView] = useInViewport<HTMLDivElement>();
84
110
  const isSweepPaused = prefersReducedMotion || !inView;
85
111
 
112
+ // Measured on the client; the server assumes a phone or a wide desktop
113
+ // column, which is what those two variants are for.
114
+ const [width, setWidth] = React.useState(isCompact ? 390 : 1200);
115
+ React.useEffect(() => {
116
+ const node = wallRef.current;
117
+ if (!node || typeof ResizeObserver === "undefined") return;
118
+ const observer = new ResizeObserver(([entry]) =>
119
+ setWidth(entry.contentRect.width),
120
+ );
121
+ observer.observe(node);
122
+ return () => observer.disconnect();
123
+ }, [wallRef]);
124
+
125
+ let cols = maxCols;
126
+ let slots: PosterWallTile[];
127
+ if (pictures.length === 0) {
128
+ slots = Array.from({ length: maxSlots }, (): PosterWallTile => ({}));
129
+ } else {
130
+ const shown = Math.min(pictures.length, maxSlots);
131
+ cols = Math.min(
132
+ maxCols,
133
+ Math.max(2, Math.round(Math.sqrt((shown * width) / heightPx))),
134
+ );
135
+ const rows = Math.max(1, Math.ceil(shown / cols));
136
+ slots = Array.from(
137
+ { length: cols * rows },
138
+ (_, index) => pictures[index % pictures.length],
139
+ );
140
+ }
141
+
86
142
  return (
87
143
  <div
88
144
  ref={wallRef}
89
145
  className={cn(
90
146
  "relative overflow-hidden rounded-[18px] bg-[oklch(0.22_0.012_60)]",
91
147
  isCompact ? "h-[330px]" : "h-[420px]",
92
- className
148
+ className,
93
149
  )}
94
150
  >
95
151
  <style>{`
@@ -100,10 +156,8 @@ export function PosterWall({ tiles, variant = "default", children, className }:
100
156
  `}</style>
101
157
 
102
158
  <div
103
- className={cn(
104
- "absolute inset-0 grid auto-rows-fr gap-[2px] opacity-50",
105
- isCompact ? "grid-cols-6" : "grid-cols-12"
106
- )}
159
+ className="absolute inset-0 grid auto-rows-fr gap-[2px] opacity-50"
160
+ style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}
107
161
  aria-hidden
108
162
  >
109
163
  {slots.map((tile, index) =>
@@ -122,7 +176,7 @@ export function PosterWall({ tiles, variant = "default", children, className }:
122
176
  key={index}
123
177
  className="h-full w-full bg-[repeating-linear-gradient(45deg,color-mix(in_oklch,white_10%,transparent)_0px,color-mix(in_oklch,white_10%,transparent)_2px,transparent_2px,transparent_8px)]"
124
178
  />
125
- )
179
+ ),
126
180
  )}
127
181
  </div>
128
182
 
@@ -157,7 +211,9 @@ export function PosterWall({ tiles, variant = "default", children, className }:
157
211
  <div
158
212
  className={cn(
159
213
  "relative flex h-full min-w-0 flex-col text-white",
160
- isCompact ? "justify-end gap-[11px] p-5" : "justify-center gap-[15px] px-12"
214
+ isCompact
215
+ ? "justify-end gap-[11px] p-5"
216
+ : "justify-center gap-[15px] px-12",
161
217
  )}
162
218
  >
163
219
  {children}
@@ -173,9 +229,14 @@ export type PosterWallSkeletonProps = {
173
229
  };
174
230
 
175
231
  /** The tile grid at rest — the sweeping light never renders in the loading state. */
176
- export function PosterWallSkeleton({ variant = "default", className }: PosterWallSkeletonProps) {
232
+ export function PosterWallSkeleton({
233
+ variant = "default",
234
+ className,
235
+ }: PosterWallSkeletonProps) {
177
236
  const isCompact = variant === "compact";
178
- const tileCount = isCompact ? POSTER_WALL_COMPACT_TILE_COUNT : POSTER_WALL_DESKTOP_TILE_COUNT;
237
+ const tileCount = isCompact
238
+ ? POSTER_WALL_COMPACT_TILE_COUNT
239
+ : POSTER_WALL_DESKTOP_TILE_COUNT;
179
240
 
180
241
  return (
181
242
  <div
@@ -183,17 +244,20 @@ export function PosterWallSkeleton({ variant = "default", className }: PosterWal
183
244
  className={cn(
184
245
  "relative overflow-hidden rounded-[18px] bg-[oklch(0.22_0.012_60)]",
185
246
  isCompact ? "h-[330px]" : "h-[420px]",
186
- className
247
+ className,
187
248
  )}
188
249
  >
189
250
  <div
190
251
  className={cn(
191
252
  "absolute inset-0 grid auto-rows-fr gap-[2px] opacity-50",
192
- isCompact ? "grid-cols-6" : "grid-cols-12"
253
+ isCompact ? "grid-cols-6" : "grid-cols-12",
193
254
  )}
194
255
  >
195
256
  {Array.from({ length: tileCount }, (_, index) => (
196
- <Skeleton key={index} className="h-full w-full rounded-none bg-white/10" />
257
+ <Skeleton
258
+ key={index}
259
+ className="h-full w-full rounded-none bg-white/10"
260
+ />
197
261
  ))}
198
262
  </div>
199
263
 
@@ -209,11 +273,20 @@ export function PosterWallSkeleton({ variant = "default", className }: PosterWal
209
273
  <div
210
274
  className={cn(
211
275
  "relative flex h-full min-w-0 flex-col",
212
- isCompact ? "justify-end gap-[11px] p-5" : "justify-center gap-[15px] px-12"
276
+ isCompact
277
+ ? "justify-end gap-[11px] p-5"
278
+ : "justify-center gap-[15px] px-12",
213
279
  )}
214
280
  >
215
- <Skeleton className={cn("bg-white/15", isCompact ? "h-[11px] w-40" : "h-3 w-52")} />
216
- <Skeleton className={cn("bg-white/20", isCompact ? "h-7 w-64" : "h-10 w-96")} />
281
+ <Skeleton
282
+ className={cn(
283
+ "bg-white/15",
284
+ isCompact ? "h-[11px] w-40" : "h-3 w-52",
285
+ )}
286
+ />
287
+ <Skeleton
288
+ className={cn("bg-white/20", isCompact ? "h-7 w-64" : "h-10 w-96")}
289
+ />
217
290
  </div>
218
291
  </div>
219
292
  );
@@ -68,3 +68,27 @@ export const LoadingVsLoaded: Story = {
68
68
  </div>
69
69
  ),
70
70
  };
71
+
72
+ /**
73
+ * The app-card header: title, count, and an accent "All ›" in the count's
74
+ * size tier. Both the tone and the subtitle are phone-scoped — resize past
75
+ * `lg` to see the muted action and the subtitle return.
76
+ */
77
+ export const AppCard: Story = {
78
+ args: {
79
+ title: "Tonight",
80
+ count: 7,
81
+ subtitle: "Everything still to start before midnight",
82
+ subtitleFrom: "lg",
83
+ action: "All 15",
84
+ actionHref: "#",
85
+ actionTone: "app",
86
+ },
87
+ decorators: [
88
+ (Story) => (
89
+ <div className="w-[366px] rounded-[18px] bg-card px-3.5 pt-4">
90
+ <Story />
91
+ </div>
92
+ ),
93
+ ],
94
+ };
@@ -0,0 +1,28 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { SectionHeader } from "./section-header";
4
+
5
+ describe("SectionHeader", () => {
6
+ it("renders title, count and a linked action", () => {
7
+ render(<SectionHeader title="Tonight" count={7} action="See all" actionHref="/events" />);
8
+ expect(screen.getByRole("heading", { name: "Tonight" })).toBeInTheDocument();
9
+ expect(screen.getByText("7")).toBeInTheDocument();
10
+ expect(screen.getByRole("link", { name: "See all" })).toHaveAttribute("href", "/events");
11
+ });
12
+
13
+ it("gives the app tone its accent classes and the muted tone none of them", () => {
14
+ const { rerender } = render(
15
+ <SectionHeader title="Tonight" action="See all" actionHref="/events" actionTone="app" />
16
+ );
17
+ expect(screen.getByRole("link", { name: "See all" }).className).toContain("text-accent");
18
+
19
+ rerender(<SectionHeader title="Tonight" action="See all" actionHref="/events" />);
20
+ expect(screen.getByRole("link", { name: "See all" }).className).not.toContain("text-accent");
21
+ });
22
+
23
+ it("keeps the subtitle in the DOM but hides it below lg with subtitleFrom", () => {
24
+ render(<SectionHeader title="Tonight" subtitle="Still to start" subtitleFrom="lg" />);
25
+ expect(screen.getByText("Still to start").className).toContain("hidden");
26
+ expect(screen.getByText("Still to start").className).toContain("lg:block");
27
+ });
28
+ });
@@ -35,6 +35,21 @@ export type SectionHeaderProps = {
35
35
  /** Trailing action label, e.g. "See all". */
36
36
  action?: string;
37
37
  actionHref?: string;
38
+ /**
39
+ * `muted` (default): 14px in `--muted-foreground`, at every width. `app`:
40
+ * BELOW `lg`, 12.5px semibold in `--accent` — the way an app's "All ›" reads
41
+ * at the end of a card's title row, quieter in size and louder in colour so
42
+ * it sits in the count's tier rather than competing with the title. From
43
+ * `lg` it falls back to `muted`: the app treatment is a phone treatment, and
44
+ * the desktop page it shares a header with is unchanged by it.
45
+ */
46
+ actionTone?: "muted" | "app";
47
+ /**
48
+ * `always` (default) shows the subtitle at every width. `lg` hides it below
49
+ * the `lg` breakpoint for surfaces that narrate on desktop and not on a
50
+ * phone, where a card title with a count is the whole header.
51
+ */
52
+ subtitleFrom?: "always" | "lg";
38
53
  /** Defaults to a plain `<a>`. Pass `next/link` in a Next app. */
39
54
  linkComponent?: React.ComponentType<SectionHeaderLinkProps>;
40
55
  onAction?: (mouseEvent: React.MouseEvent) => void;
@@ -48,24 +63,38 @@ export type SectionHeaderProps = {
48
63
  className?: string;
49
64
  };
50
65
 
51
- const ACTION_CLASSES = cn(
52
- "inline-flex shrink-0 items-center gap-0.5 rounded-sm text-[14px] text-muted-foreground",
53
- "transition-colors duration-150 hover:text-foreground motion-reduce:transition-none",
66
+ // `-my-2 py-2 -mr-1 pr-1`: the label keeps its visual size while its hit area
67
+ // grows to the 44px minimum. Negative margins cancel the padding so the row
68
+ // height and the right edge do not move.
69
+ const ACTION_BASE_CLASSES = cn(
70
+ "inline-flex min-h-11 shrink-0 items-center gap-0.5 rounded-sm -my-2 py-2 -mr-1 pr-1",
71
+ "transition-colors duration-150 motion-reduce:transition-none",
54
72
  "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
55
73
  );
56
74
 
75
+ const ACTION_TONE_CLASSES = {
76
+ muted: "text-[14px] text-muted-foreground hover:text-foreground",
77
+ app: cn(
78
+ "text-[12.5px] font-semibold text-accent hover:text-accent/80",
79
+ "lg:text-[14px] lg:font-normal lg:text-muted-foreground lg:hover:text-foreground"
80
+ ),
81
+ } as const;
82
+
57
83
  export function SectionHeader({
58
84
  title,
59
85
  count,
60
86
  subtitle,
61
87
  action,
62
88
  actionHref,
89
+ actionTone = "muted",
90
+ subtitleFrom = "always",
63
91
  linkComponent: LinkComponent = DefaultLink,
64
92
  onAction,
65
93
  as: Heading = "h2",
66
94
  className,
67
95
  }: SectionHeaderProps) {
68
96
  const actionIcon = <ChevronRight className="h-3.5 w-3.5" aria-hidden />;
97
+ const actionClasses = cn(ACTION_BASE_CLASSES, ACTION_TONE_CLASSES[actionTone]);
69
98
 
70
99
  return (
71
100
  <div className={cn("flex flex-col mb-3.5", className)}>
@@ -75,18 +104,20 @@ export function SectionHeader({
75
104
  {title}
76
105
  </Heading>
77
106
  {count !== undefined && (
78
- <span className="ml-0.5 shrink-0 text-[12px] text-muted-foreground">{count}</span>
107
+ <span className="ml-1.5 shrink-0 text-[12px] tabular-nums text-muted-foreground lg:ml-0.5">
108
+ {count}
109
+ </span>
79
110
  )}
80
111
  </div>
81
112
 
82
113
  {action &&
83
114
  (actionHref ? (
84
- <LinkComponent href={actionHref} className={ACTION_CLASSES} onClick={onAction}>
115
+ <LinkComponent href={actionHref} className={actionClasses} onClick={onAction}>
85
116
  {action}
86
117
  {actionIcon}
87
118
  </LinkComponent>
88
119
  ) : onAction ? (
89
- <button type="button" className={ACTION_CLASSES} onClick={onAction}>
120
+ <button type="button" className={actionClasses} onClick={onAction}>
90
121
  {action}
91
122
  {actionIcon}
92
123
  </button>
@@ -94,7 +125,14 @@ export function SectionHeader({
94
125
  </div>
95
126
 
96
127
  {subtitle && (
97
- <p className="mt-0.5 text-[13px] text-muted-foreground md:text-[14px]">{subtitle}</p>
128
+ <p
129
+ className={cn(
130
+ "mt-0.5 text-[13px] text-muted-foreground md:text-[14px]",
131
+ subtitleFrom === "lg" && "hidden lg:block"
132
+ )}
133
+ >
134
+ {subtitle}
135
+ </p>
98
136
  )}
99
137
  </div>
100
138
  );