@four-leaf-studios/rl-overlay 1.0.0

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 (68) hide show
  1. package/dist/index.cjs.js +10718 -0
  2. package/dist/index.cjs.js.map +1 -0
  3. package/dist/index.esm.js +10691 -0
  4. package/dist/index.esm.js.map +1 -0
  5. package/dist/types/Overlay.d.ts +19 -0
  6. package/dist/types/Player.d.ts +7 -0
  7. package/dist/types/PlayerBoost.d.ts +8 -0
  8. package/dist/types/Replay.d.ts +3 -0
  9. package/dist/types/Scoreboard.d.ts +3 -0
  10. package/dist/types/ScoreboardGameBox.d.ts +7 -0
  11. package/dist/types/ScoreboardSeriesBox.d.ts +7 -0
  12. package/dist/types/ScoreboardTeam.d.ts +7 -0
  13. package/dist/types/StatItem.d.ts +13 -0
  14. package/dist/types/TargetBoost.d.ts +3 -0
  15. package/dist/types/TargetPlayer.d.ts +3 -0
  16. package/dist/types/TargetPlayerLocation.d.ts +7 -0
  17. package/dist/types/TargetPlayerStats.d.ts +7 -0
  18. package/dist/types/Team.d.ts +8 -0
  19. package/dist/types/Teams.d.ts +3 -0
  20. package/dist/types/Timer.d.ts +3 -0
  21. package/dist/types/context/BroadcastContext.d.ts +7 -0
  22. package/dist/types/hooks/index.d.ts +4 -0
  23. package/dist/types/hooks/useOverlayStyles.d.ts +12 -0
  24. package/dist/types/hooks/useReplay.d.ts +7 -0
  25. package/dist/types/hooks/useShowGameComponents.d.ts +2 -0
  26. package/dist/types/hooks/useTargetPlayer.d.ts +2 -0
  27. package/dist/types/index.d.ts +3 -0
  28. package/dist/types/mockBroadcast.d.ts +18 -0
  29. package/package.json +50 -0
  30. package/rollup.config.js +55 -0
  31. package/src/Overlay.tsx +100 -0
  32. package/src/Player.tsx +31 -0
  33. package/src/PlayerBoost.tsx +26 -0
  34. package/src/Replay.tsx +18 -0
  35. package/src/Scoreboard.tsx +58 -0
  36. package/src/ScoreboardGameBox.tsx +25 -0
  37. package/src/ScoreboardSeriesBox.tsx +44 -0
  38. package/src/ScoreboardTeam.tsx +39 -0
  39. package/src/StatItem.tsx +31 -0
  40. package/src/TargetBoost.tsx +63 -0
  41. package/src/TargetPlayer.tsx +56 -0
  42. package/src/TargetPlayerLocation.tsx +27 -0
  43. package/src/TargetPlayerStats.tsx +25 -0
  44. package/src/Team.tsx +45 -0
  45. package/src/Teams.tsx +13 -0
  46. package/src/Timer.tsx +24 -0
  47. package/src/assets/overlay-testing-background.png +0 -0
  48. package/src/context/BroadcastContext.tsx +21 -0
  49. package/src/css/reset.css +280 -0
  50. package/src/hooks/index.ts +4 -0
  51. package/src/hooks/useOverlayStyles.ts +107 -0
  52. package/src/hooks/useReplay.ts +21 -0
  53. package/src/hooks/useShowGameComponents.ts +14 -0
  54. package/src/hooks/useTargetPlayer.ts +21 -0
  55. package/src/index.ts +3 -0
  56. package/src/types.d.ts +59 -0
  57. package/test-overlay/README.md +12 -0
  58. package/test-overlay/eslint.config.js +33 -0
  59. package/test-overlay/index.html +13 -0
  60. package/test-overlay/package.json +27 -0
  61. package/test-overlay/public/mock-css.css +733 -0
  62. package/test-overlay/public/vite.svg +1 -0
  63. package/test-overlay/src/App.jsx +28 -0
  64. package/test-overlay/src/index.css +0 -0
  65. package/test-overlay/src/main.jsx +10 -0
  66. package/test-overlay/src/mockBroadcast.js +33 -0
  67. package/test-overlay/vite.config.js +7 -0
  68. package/tsconfig.json +21 -0
@@ -0,0 +1,31 @@
1
+ // StatItem.tsx
2
+ import React from "react";
3
+ import { PlayerState } from "@four-leaf-studios/rl-socket-hook";
4
+
5
+ // 1. Define what “primitive” means for us:
6
+ type Primitive = string | number | boolean;
7
+
8
+ // 2. Extract just the keys whose values are Primitive:
9
+ type PrimitiveKeys<T> = {
10
+ [K in keyof T]: T[K] extends Primitive ? K : never;
11
+ }[keyof T];
12
+
13
+ // 3. Now K can only be one of those “safe” keys:
14
+ type StatItemProps<K extends PrimitiveKeys<PlayerState>> = {
15
+ id: K;
16
+ label: string;
17
+ value: PlayerState[K]; // guaranteed to be string|number|boolean
18
+ };
19
+
20
+ const StatItem = <K extends PrimitiveKeys<PlayerState>>({
21
+ id,
22
+ label,
23
+ value,
24
+ }: StatItemProps<K>) => (
25
+ <li className={`stat_box_statistic stat_box_statistic_player_${id}`}>
26
+ <span className="stat_box_statistic_name">{label}</span>
27
+ <span className="stat_box_statistic_value">{value}</span>
28
+ </li>
29
+ );
30
+
31
+ export default StatItem;
@@ -0,0 +1,63 @@
1
+ import { useEventSelector } from "@four-leaf-studios/rl-socket-hook";
2
+ import { AnimatePresence, motion } from "framer-motion";
3
+ import React, { memo } from "react";
4
+ import useTargetPlayer from "./hooks/useTargetPlayer";
5
+
6
+ const TargetBoost = () => {
7
+ const initialized = useEventSelector(
8
+ "game:update_state",
9
+ (state) => state?.hasGame
10
+ );
11
+ const targetPlayer = useTargetPlayer();
12
+
13
+ if (!targetPlayer || !initialized) return null;
14
+
15
+ const modifier = targetPlayer.team === 0 ? "left" : "right";
16
+
17
+ return (
18
+ <AnimatePresence mode="wait">
19
+ <motion.div
20
+ key={targetPlayer.id}
21
+ className={`target_boost ${modifier}_target_boost`}
22
+ initial={{ x: 400, opacity: 0 }}
23
+ animate={{ x: 0, opacity: 1 }}
24
+ exit={{ x: 400, opacity: 0, scale: 0.9 }}
25
+ transition={{ duration: 1, ease: [0.77, 0, 0.175, 1] }} // Quartic easing equivalent
26
+ >
27
+ <div
28
+ className={`target_boost_container ${modifier}_target_boost_container`}
29
+ >
30
+ <div
31
+ className={`target_boost_meter ${modifier}_target_boost_meter`}
32
+ style={
33
+ { "--target_boost": targetPlayer.boost } as React.CSSProperties
34
+ }
35
+ >
36
+ <div
37
+ className={`target_boost_meter_inner ${modifier}_target_boost_meter_inner`}
38
+ ></div>
39
+ </div>
40
+ <svg className="target_boost_svg">
41
+ <circle
42
+ className={`target_boost_svg_circle ${modifier}_target_boost_svg_circle`}
43
+ cx="50%"
44
+ cy="50%"
45
+ r="50%"
46
+ />
47
+ </svg>
48
+ </div>
49
+ <motion.div
50
+ className={`target_boost_value ${modifier}_target_boost_value`}
51
+ initial={{ scale: 0 }}
52
+ animate={{ scale: 1 }}
53
+ exit={{ scale: 0 }}
54
+ transition={{ duration: 0.5, ease: "easeOut" }}
55
+ >
56
+ {targetPlayer.boost}
57
+ </motion.div>
58
+ </motion.div>
59
+ </AnimatePresence>
60
+ );
61
+ };
62
+
63
+ export default memo(TargetBoost);
@@ -0,0 +1,56 @@
1
+ import { useEventSelector } from "@four-leaf-studios/rl-socket-hook";
2
+ import { AnimatePresence, motion } from "framer-motion";
3
+ import React, { memo } from "react";
4
+ import { useBroadcast } from "./context/BroadcastContext";
5
+ import PlayerBoost from "./PlayerBoost";
6
+ import TargetPlayerStats from "./TargetPlayerStats";
7
+ import useTargetPlayer from "./hooks/useTargetPlayer";
8
+
9
+ const TargetPlayer = () => {
10
+ const broadcast = useBroadcast();
11
+ const targetPlayer = useTargetPlayer();
12
+
13
+ if (!targetPlayer || !broadcast) return null;
14
+
15
+ const teamColor = broadcast.teams[targetPlayer.team].color?.primary_color;
16
+ const modifier = targetPlayer.team === 0 ? "left" : "right";
17
+
18
+ return (
19
+ <AnimatePresence mode="wait">
20
+ <motion.div
21
+ key={targetPlayer.id}
22
+ className={`stat_box ${modifier}_stat_box`}
23
+ style={{ "--team-color": teamColor } as React.CSSProperties}
24
+ initial={{ x: modifier === "left" ? -300 : 300, opacity: 0 }}
25
+ animate={{ x: 0, opacity: 1 }}
26
+ exit={{ x: modifier === "left" ? -300 : 300, opacity: 0 }}
27
+ transition={{ duration: 0.8, ease: [0.77, 0, 0.175, 1] }} // Quartic easing equivalent
28
+ >
29
+ <motion.div
30
+ className={`stat_box_player ${modifier}_stat_box_player`}
31
+ initial={{ opacity: 0, scale: 0.8 }}
32
+ animate={{ opacity: 1, scale: 1 }}
33
+ transition={{ duration: 0.5, ease: "easeOut" }}
34
+ >
35
+ {targetPlayer.name}
36
+ </motion.div>
37
+
38
+ {/* Player Stats */}
39
+ <TargetPlayerStats targetPlayer={targetPlayer} />
40
+
41
+ {/* Player Boost */}
42
+ <motion.div
43
+ className={`stat_box_player_boost ${modifier}_stat_box_player_boost`}
44
+ initial={{ opacity: 0, y: 10 }}
45
+ animate={{ opacity: 1, y: 0 }}
46
+ exit={{ opacity: 0, y: 10 }}
47
+ transition={{ duration: 0.5, ease: "easeOut" }}
48
+ >
49
+ <PlayerBoost boost={targetPlayer.boost} team={targetPlayer.team} />
50
+ </motion.div>
51
+ </motion.div>
52
+ </AnimatePresence>
53
+ );
54
+ };
55
+
56
+ export default memo(TargetPlayer);
@@ -0,0 +1,27 @@
1
+ import React, { memo } from "react";
2
+ import { motion } from "framer-motion";
3
+ import { PlayerState } from "@four-leaf-studios/rl-socket-hook";
4
+
5
+ type Props = {
6
+ location: PlayerState["location"];
7
+ };
8
+
9
+ const TargetPlayerLocation = ({ location }: Props) => {
10
+ return (
11
+ <motion.div
12
+ className="stat_box_statistic stat_box_statistic_player_location"
13
+ initial={{ opacity: 0, y: 10 }}
14
+ animate={{ opacity: 1, y: 0 }}
15
+ exit={{ opacity: 0, y: 10 }}
16
+ transition={{ duration: 0.5 }}
17
+ >
18
+ <span className="stat_box_statistic_name">Location</span>{" "}
19
+ <span className="stat_box_statistic_value">
20
+ X {location.X?.toFixed(2) || 0}, Y {location.Y?.toFixed(2) || 0}, Z{" "}
21
+ {location.Z?.toFixed(2) || 0}
22
+ </span>
23
+ </motion.div>
24
+ );
25
+ };
26
+
27
+ export default memo(TargetPlayerLocation);
@@ -0,0 +1,25 @@
1
+ // TargetPlayerStats.tsx
2
+ import React, { memo } from "react";
3
+ import { PlayerState } from "@four-leaf-studios/rl-socket-hook";
4
+ import StatItem from "./StatItem";
5
+ import TargetPlayerLocation from "./TargetPlayerLocation";
6
+
7
+ type Props = {
8
+ targetPlayer: PlayerState;
9
+ };
10
+
11
+ const TargetPlayerStats = ({ targetPlayer }: Props) => (
12
+ <ul className="stat_box_statistics">
13
+ <StatItem id="id" label="ID" value={targetPlayer.id} />
14
+ <StatItem id="team" label="Team" value={targetPlayer.team} />
15
+ <StatItem id="score" label="Score" value={targetPlayer.score} />
16
+ <StatItem id="goals" label="Goals" value={targetPlayer.goals} />
17
+ <StatItem id="assists" label="Assists" value={targetPlayer.assists} />
18
+ <StatItem id="shots" label="Shots" value={targetPlayer.shots} />
19
+ <StatItem id="saves" label="Saves" value={targetPlayer.saves} />
20
+ <StatItem id="touches" label="Touches" value={targetPlayer.touches} />
21
+ <TargetPlayerLocation location={targetPlayer.location} />
22
+ </ul>
23
+ );
24
+
25
+ export default memo(TargetPlayerStats);
package/src/Team.tsx ADDED
@@ -0,0 +1,45 @@
1
+ import { useEventSelector } from "@four-leaf-studios/rl-socket-hook";
2
+ import { motion, AnimatePresence } from "framer-motion";
3
+ import React, { memo } from "react";
4
+ import { Team } from "./types";
5
+ import Player from "./Player";
6
+
7
+ type Props = {
8
+ id: Team["id"];
9
+ };
10
+
11
+ const Team = ({ id }: Props) => {
12
+ const players = useEventSelector("game:update_state", (state) => {
13
+ return (
14
+ state?.players &&
15
+ Object.values(state?.players).filter(
16
+ (player) => player.team.toString() === id
17
+ )
18
+ );
19
+ });
20
+
21
+ const isLeft = id === "0";
22
+ const modifier = isLeft ? "left" : "right";
23
+
24
+ if (!players) return null;
25
+
26
+ return (
27
+ <motion.div
28
+ className={`team_box ${modifier}_team_box`}
29
+ initial={{ x: isLeft ? -300 : 300, opacity: 0 }}
30
+ animate={{ x: 0, opacity: 1 }}
31
+ exit={{ x: isLeft ? -300 : 300, opacity: 0 }}
32
+ transition={{ duration: 1, ease: [0.77, 0, 0.175, 1] }}
33
+ >
34
+ <AnimatePresence>
35
+ {players.map((player) => (
36
+ <Player player={player} key={player.id} />
37
+ ))}
38
+ </AnimatePresence>
39
+ </motion.div>
40
+ );
41
+ };
42
+
43
+ const MemoizedTeam = memo(Team);
44
+ MemoizedTeam.displayName = "Team";
45
+ export default MemoizedTeam;
package/src/Teams.tsx ADDED
@@ -0,0 +1,13 @@
1
+ import React from "react";
2
+ import Team from "./Team";
3
+
4
+ const Teams = () => {
5
+ return (
6
+ <>
7
+ <Team id={"0"} />
8
+ <Team id={"1"} />
9
+ </>
10
+ );
11
+ };
12
+
13
+ export default Teams;
package/src/Timer.tsx ADDED
@@ -0,0 +1,24 @@
1
+ import { useEventSelector } from "@four-leaf-studios/rl-socket-hook";
2
+ import React from "react";
3
+ import { memo } from "react";
4
+
5
+ const TimerComponent: React.FC = () => {
6
+ // Add proper null checks to prevent "Cannot read properties of undefined" error
7
+ const time_seconds = useEventSelector(
8
+ "game:update_state",
9
+ (state) => state?.game?.time_seconds
10
+ );
11
+
12
+ // Return null if time_seconds is not available
13
+ if (typeof time_seconds !== "number") return null;
14
+
15
+ const minutes = Math.floor(time_seconds / 60);
16
+ const seconds = time_seconds % 60;
17
+ const formattedTime = `${minutes}:${seconds < 10 ? "0" : ""}${seconds}`;
18
+
19
+ return <span className="time_box">{formattedTime}</span>;
20
+ };
21
+
22
+ const Timer = memo(TimerComponent);
23
+ Timer.displayName = "Timer";
24
+ export default Timer;
@@ -0,0 +1,21 @@
1
+ import React, { createContext, useContext, ReactNode } from "react";
2
+ import { Broadcast } from "../types";
3
+
4
+ const BroadcastContext = createContext<Broadcast | undefined>(undefined);
5
+
6
+ export const BroadcastProvider: React.FC<{
7
+ broadcast: Broadcast;
8
+ children: ReactNode;
9
+ }> = ({ broadcast, children }) => (
10
+ <BroadcastContext.Provider value={broadcast}>
11
+ {children}
12
+ </BroadcastContext.Provider>
13
+ );
14
+
15
+ export function useBroadcast(): Broadcast {
16
+ const ctx = useContext(BroadcastContext);
17
+ if (ctx === undefined) {
18
+ throw new Error("useBroadcast must be used within a BroadcastProvider");
19
+ }
20
+ return ctx;
21
+ }
@@ -0,0 +1,280 @@
1
+ /* overlay-reset.css */
2
+ .overlay * {
3
+ all: unset;
4
+ line-height: initial;
5
+ }
6
+
7
+ .overlay.testing {
8
+ background: url("../assets/overlay-testing-background.png");
9
+ }
10
+
11
+ body {
12
+ background-color: transparent !important;
13
+ }
14
+
15
+ .scoreboard_box {
16
+ display: flex;
17
+ flex-flow: column;
18
+ }
19
+
20
+ .replay_box {
21
+ position: absolute;
22
+ }
23
+ .top_bar {
24
+ display: flex;
25
+ flex-flow: row;
26
+ justify-content: center;
27
+ text-align: center;
28
+ width: fit-content;
29
+ align-items: center;
30
+ position: relative;
31
+ top: 0;
32
+ left: 50%;
33
+ transform: translate(-50%);
34
+ padding-top: 0;
35
+ color: #fff;
36
+ }
37
+ .main_bar {
38
+ display: flex;
39
+ flex-flow: row;
40
+ justify-content: center;
41
+ text-align: center;
42
+ position: relative;
43
+ top: 0;
44
+ left: 50%;
45
+ transform: translate(-50%);
46
+ padding-top: 0;
47
+ color: #fff;
48
+ }
49
+
50
+ .bottom_bar {
51
+ display: flex;
52
+ flex-flow: row;
53
+ justify-content: center;
54
+ text-align: center;
55
+ width: fit-content;
56
+ position: relative;
57
+ top: 0;
58
+ left: 50%;
59
+ transform: translate(-50%);
60
+ padding-top: 0;
61
+ color: #fff;
62
+ }
63
+
64
+ .scoreboard_team_box {
65
+ display: flex;
66
+ }
67
+ .left_scoreboard_team_box {
68
+ order: 0;
69
+ }
70
+
71
+ .right_scoreboard_team_box {
72
+ order: 2;
73
+ }
74
+
75
+ .time_box {
76
+ order: 1;
77
+ }
78
+
79
+ /* Left Side of scoreboard */
80
+ .left_score_box {
81
+ order: 3;
82
+ }
83
+
84
+ .left_name_box {
85
+ order: 2;
86
+ }
87
+
88
+ .left_logo_box {
89
+ order: 1;
90
+ }
91
+
92
+ /* Righ Side */
93
+ .right_score_box {
94
+ order: 1;
95
+ }
96
+
97
+ .right_name_box {
98
+ order: 2;
99
+ }
100
+
101
+ .right_logo_box {
102
+ order: 3;
103
+ }
104
+
105
+ /* Default Styles */
106
+ .logo_box {
107
+ object-fit: scale-down;
108
+ box-sizing: border-box;
109
+ }
110
+
111
+ .name_box {
112
+ display: flex;
113
+ justify-content: center;
114
+ align-items: center;
115
+ text-align: center;
116
+ }
117
+
118
+ .series_box {
119
+ height: 100%;
120
+ display: flex;
121
+ justify-content: center;
122
+ align-items: center;
123
+ text-align: center;
124
+ }
125
+
126
+ .left_series_box {
127
+ order: 0;
128
+ }
129
+
130
+ .right_series_box {
131
+ order: 2;
132
+ flex-direction: row-reverse;
133
+ }
134
+
135
+ .score_box,
136
+ .time_box {
137
+ display: flex;
138
+ justify-content: center;
139
+ align-items: center;
140
+ }
141
+
142
+ .game_box {
143
+ order: 1;
144
+ height: 100%;
145
+ display: flex;
146
+ justify-content: center;
147
+ align-items: center;
148
+ text-align: center;
149
+ }
150
+
151
+ .team_box {
152
+ display: flex;
153
+ flex-flow: column;
154
+ color: #fff;
155
+ }
156
+
157
+ .player_box {
158
+ display: flex;
159
+ position: relative;
160
+ }
161
+
162
+ .boost_meter_bar {
163
+ height: 100%;
164
+ transition: width 0.35s;
165
+ display: block;
166
+ }
167
+
168
+ .right_boost_meter_bar {
169
+ margin-left: auto;
170
+ }
171
+
172
+ .stat_box {
173
+ display: flex;
174
+ position: absolute;
175
+ bottom: 0;
176
+ left: 0;
177
+ color: #fff;
178
+ align-items: center;
179
+ }
180
+
181
+ .stat_box_statistics {
182
+ display: flex;
183
+ }
184
+
185
+ .stat_box_statistic_name {
186
+ order: 2;
187
+ }
188
+
189
+ .stat_box_statistic_value {
190
+ font-weight: bold;
191
+ }
192
+
193
+ .stat_box_player_boost {
194
+ margin-top: 5px;
195
+ }
196
+
197
+ /* Target Boost */
198
+ .target_boost {
199
+ position: absolute;
200
+ bottom: 0;
201
+ left: 0;
202
+ color: #fff;
203
+ align-items: center;
204
+ }
205
+
206
+ .target_boost_container {
207
+ display: flex;
208
+ position: relative;
209
+ bottom: 0;
210
+ left: 0;
211
+ color: #fff;
212
+ align-items: center;
213
+ }
214
+
215
+ .target_boost_meter {
216
+ position: absolute;
217
+ inset: 0;
218
+ border-radius: 50%;
219
+ }
220
+
221
+ .left_target_boost_meter {
222
+ background: conic-gradient(
223
+ var(--team-left-primary, #0052cc) calc(var(--target_boost) * 1%),
224
+ transparent calc(var(--target_boost) * 1%)
225
+ );
226
+ }
227
+
228
+ .right_target_boost_meter {
229
+ background: conic-gradient(
230
+ var(--team-right-primary, #ff6600) calc(var(--target_boost) * 1%),
231
+ transparent calc(var(--target_boost) * 1%)
232
+ );
233
+ }
234
+
235
+ .target_boost_meter_inner {
236
+ position: absolute;
237
+ inset: 5px;
238
+ border-radius: 50%;
239
+ background-color: rgba(0, 0, 0, 0.7);
240
+ }
241
+
242
+ .target_boost_svg {
243
+ position: absolute;
244
+ width: 100%;
245
+ height: 100%;
246
+ }
247
+
248
+ .target_boost_svg_circle {
249
+ fill: none;
250
+ stroke: rgba(255, 255, 255, 0.2);
251
+ stroke-width: 2;
252
+ }
253
+
254
+ .target_boost_value {
255
+ position: absolute;
256
+ inset: 0;
257
+ display: flex;
258
+ align-items: center;
259
+ justify-content: center;
260
+ font-size: 24px;
261
+ font-weight: bold;
262
+ }
263
+
264
+ /* Replay Box */
265
+ .replay_box {
266
+ position: absolute;
267
+ top: 50%;
268
+ left: 50%;
269
+ transform: translate(-50%, -50%);
270
+ background-color: rgba(0, 0, 0, 0.7);
271
+ padding: 10px 20px;
272
+ border-radius: 4px;
273
+ }
274
+
275
+ .replay_text {
276
+ font-size: 24px;
277
+ font-weight: bold;
278
+ text-transform: uppercase;
279
+ letter-spacing: 2px;
280
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./useOverlayStyles";
2
+ export * from "./useReplay";
3
+ export * from "./useShowGameComponents";
4
+ export * from "./useTargetPlayer";
@@ -0,0 +1,107 @@
1
+ // src/hooks/useOverlayStyles.ts
2
+ import { useEffect } from "react";
3
+ import jsonToCss from "@rysh/json-to-css";
4
+ import type { Broadcast, Color } from "../types";
5
+
6
+ export type CSSJSON = {
7
+ [selector: string]: {
8
+ [prop: string]: string | number;
9
+ };
10
+ };
11
+
12
+ /**
13
+ * Hook that injects both team-color variables (from Broadcast)
14
+ * and optional custom styles (string or JSON) into the document.
15
+ * Uses `@rysh/json-to-css` for JSON → CSS conversion.
16
+ */
17
+ export function useOverlayStyles(
18
+ broadcast: Broadcast,
19
+ styles?: string | CSSJSON
20
+ ) {
21
+ useEffect(() => {
22
+ if (!broadcast?.teams) return;
23
+
24
+ const shade = (c: string, pct: number): string => {
25
+ const hex = c.replace("#", "");
26
+ const num = parseInt(hex, 16);
27
+ const amt = Math.round(2.55 * pct);
28
+ let R = (num >> 16) + amt;
29
+ let G = ((num >> 8) & 0xff) + amt;
30
+ let B = (num & 0xff) + amt;
31
+ R = Math.min(255, Math.max(0, R));
32
+ G = Math.min(255, Math.max(0, G));
33
+ B = Math.min(255, Math.max(0, B));
34
+ return (
35
+ "#" +
36
+ ((1 << 24) + (R << 16) + (G << 8) + B)
37
+ .toString(16)
38
+ .slice(1)
39
+ .toUpperCase()
40
+ );
41
+ };
42
+
43
+ const defaultBlue: Color = {
44
+ id: "",
45
+ name: "",
46
+ primary_color: "#0052cc",
47
+ secondary_color: "#ffffff",
48
+ mutual_color: "#00a8ff",
49
+ created_at: "",
50
+ created_by: null,
51
+ };
52
+ const defaultOrange: Color = {
53
+ id: "",
54
+ name: "",
55
+ primary_color: "#ff6600",
56
+ secondary_color: "#ffffff",
57
+ mutual_color: "#ffaa00",
58
+ created_at: "",
59
+ created_by: null,
60
+ };
61
+
62
+ const blueColor =
63
+ broadcast.teams.find((t) => t.side === "blue")?.color ?? defaultBlue;
64
+ const orangeColor =
65
+ broadcast.teams.find((t) => t.side === "orange")?.color ?? defaultOrange;
66
+
67
+ const teamJSON: CSSJSON = {
68
+ ":root": {
69
+ "--team-left-primary": blueColor.primary_color,
70
+ "--team-left-primary-light": shade(blueColor.primary_color, 20),
71
+ "--team-left-primary-dark": shade(blueColor.primary_color, -20),
72
+ "--team-left-secondary": blueColor.secondary_color,
73
+ "--team-left-mutual":
74
+ blueColor.mutual_color ?? defaultBlue.mutual_color!,
75
+
76
+ "--team-right-primary": orangeColor.primary_color,
77
+ "--team-right-primary-light": shade(orangeColor.primary_color, 20),
78
+ "--team-right-primary-dark": shade(orangeColor.primary_color, -20),
79
+ "--team-right-secondary": orangeColor.secondary_color,
80
+ "--team-right-mutual":
81
+ orangeColor.mutual_color ?? defaultOrange.mutual_color!,
82
+ },
83
+ };
84
+
85
+ // ==== CHANGE HERE: pass a second arg (root selector) ====
86
+ let cssText: string;
87
+ if (styles && typeof styles === "object") {
88
+ const merged: CSSJSON = { ...teamJSON, ...styles };
89
+ cssText = jsonToCss(merged, ""); // <- empty string scopes at top
90
+ } else {
91
+ cssText = jsonToCss(teamJSON, ""); // <- ditto
92
+ if (typeof styles === "string") {
93
+ cssText += "\n\n" + styles;
94
+ }
95
+ }
96
+
97
+ const styleEl = document.createElement("style");
98
+ styleEl.id = "overlay-styles";
99
+ styleEl.textContent = cssText;
100
+ document.getElementById("overlay-styles")?.remove();
101
+ document.head.appendChild(styleEl);
102
+
103
+ return () => {
104
+ document.getElementById("overlay-styles")?.remove();
105
+ };
106
+ }, [broadcast, styles]);
107
+ }