@lumiastream/lumia-types 3.2.6 → 3.2.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.
@@ -2,45 +2,153 @@
2
2
 
3
3
  > Need to inspect what an alert returns? Open the [alert explorer](/docs/variables#alert-explorer) on the Platform Variables page to browse `data.alert`, `data.extraSettings.*`, and the fields available for each alert.
4
4
 
5
+ ## Index
6
+
7
+ Grouped by what the overlay does, so you can jump to the closest starting point:
8
+
9
+ **Alerts & chat**
10
+
11
+ - [Custom Alert](#custom-alert) — single listener branches on `data.alert` for donations, subs, follows, bits, raids.
12
+ - [Custom Chat Box](#custom-chat-box) — renders chat messages with DOM APIs (no `innerHTML`).
13
+
14
+ **Commands & games**
15
+
16
+ - [Roll a Dice](#roll-a-dice) — `!roll` chat command with animation.
17
+ - [Pokemon Catch Mini-Game Overlay](#pokemon-catch-mini-game-overlay) — full game with storage, chatbot, and SFX.
18
+ - [Calculator with overlaySendCustomContent from Lumia Stream](#calculator-with-overlaysendcustomcontent-from-lumia-stream) — receives content from a Lumia command via `overlaycontent` listener.
19
+
20
+ **External APIs (fetch)**
21
+
22
+ - [Anime Facts using Fetch](#anime-facts-using-fetch)
23
+ - [Pet Cam Random Dog/Cat Images using Fetch](#pet-cam-random-dogcat-images-using-fetch)
24
+
25
+ **HFX / lights / loyalty**
26
+
27
+ - [HFX Listener Banner](#hfx-listener-banner) — `Overlay.on("hfx", ...)` triggers an animated banner with the user, command, and message.
28
+ - [Virtual Light Monitor](#virtual-light-monitor) — `Overlay.on("virtuallight", ...)` shows the current light color/brightness/power; persists across reloads with `saveStorage` + first-load null-check.
29
+ - [Loyalty Points Leaderboard](#loyalty-points-leaderboard) — `Overlay.getLoyaltyPoints` / `addLoyaltyPoints` + `Overlay.chatbot` for `!points` and `!give` commands.
30
+
31
+ **Visual**
32
+
33
+ - [Art Canvas](#art-canvas) — canvas rendering.
34
+ - [Font Picker with Quoted Variable](#font-picker-with-quoted-variable) — small reference for how Google fonts must be quoted in CSS.
35
+
36
+ ## Font Picker with Quoted Variable
37
+
38
+ Small reference overlay. The key pattern: `fontpicker` Configs produce a font-family string, and CSS needs that value inside quotes because `font-family` expects a string. Compare to colors/sizes, which must be unquoted.
39
+
40
+ ### HTML
41
+
42
+ ```html
43
+ <div id="label">Hello, streamer!</div>
44
+ ```
45
+
46
+ ### CSS
47
+
48
+ ```css
49
+ #label {
50
+ /* quoted — font-family needs a string */
51
+ font-family: "{{font}}";
52
+ /* unquoted — color/size/number values are not strings */
53
+ color: {{labelColor}};
54
+ font-size: {{fontSize}}px;
55
+ padding: 16px 24px;
56
+ }
57
+ ```
58
+
59
+ ### JS
60
+
61
+ ```js
62
+ // Nothing needed — CSS variable replacement renders the picked font automatically.
63
+ ```
64
+
65
+ ### Configs
66
+
67
+ ```json
68
+ {
69
+ "font": {
70
+ "type": "fontpicker",
71
+ "label": "Font",
72
+ "order": 1
73
+ },
74
+ "labelColor": {
75
+ "type": "colorpicker",
76
+ "label": "Label color",
77
+ "order": 2,
78
+ "value": "#ffffff"
79
+ },
80
+ "fontSize": {
81
+ "type": "slider",
82
+ "label": "Font size",
83
+ "order": 3,
84
+ "options": { "min": 10, "max": 120, "step": 2, "suffix": "px" },
85
+ "value": 48
86
+ }
87
+ }
88
+ ```
89
+
90
+ ### Data
91
+
92
+ ```json
93
+ {
94
+ "font": "Roboto",
95
+ "labelColor": "#ffffff",
96
+ "fontSize": 48
97
+ }
98
+ ```
99
+
5
100
  ## Custom Alert
6
101
 
7
102
  ### JS Code
8
103
 
9
104
  ```js
10
105
  // Overlay.data is fetched from the sidebar input values
11
- const container = document.getElementById('container');
106
+ const container = document.getElementById("container");
107
+ const alertContainer = document.getElementById("alert-container");
12
108
 
13
109
  // Pass data to css variable to use it easily inside css
14
- if (Overlay.data?.primaryColor) container.style.setProperty('--primary', Overlay.data?.primaryColor);
15
- if (Overlay.data?.backgroundColor) container.style.setProperty('--secondary', Overlay.data?.backgroundColor);
16
- if (Overlay.data?.messageBgColor) container.style.setProperty('--bg', Overlay.data?.messageBgColor);
17
- if (Overlay.data?.textColor) container.style.setProperty('--text', Overlay.data?.textColor);
18
- if (Overlay.data?.rounded) container.style.setProperty('--rounded', Overlay.data?.rounded);
110
+ if (Overlay.data?.primaryColor) container.style.setProperty("--primary", Overlay.data.primaryColor);
111
+ if (Overlay.data?.backgroundColor) container.style.setProperty("--secondary", Overlay.data.backgroundColor);
112
+ if (Overlay.data?.messageBgColor) container.style.setProperty("--bg", Overlay.data.messageBgColor);
113
+ if (Overlay.data?.textColor) container.style.setProperty("--text", Overlay.data.textColor);
114
+ if (Overlay.data?.rounded) container.style.setProperty("--rounded", Overlay.data.rounded);
19
115
 
20
116
  // Listen for alerts
21
- Overlay.on('alert', (data) => {
117
+ Overlay.on("alert", (data) => {
22
118
  const alertType = data.alert;
23
- const settings = data.extraSettings;
24
- let fullMessage = '';
25
-
26
- if (alertType.includes('donat')) {
27
- fullMessage += `${settings?.username} just tipped ${settings?.amount ?? ''} ${settings?.currency ?? ''}`;
28
- } else if (alertType.includes('follow')) {
29
- fullMessage += `${settings?.username} is now following!`;
30
- } else if (alertType.includes('firstChatter')) {
31
- fullMessage += `${settings?.username} is the first chatter`;
32
- } else if (alertType.includes('entrance')) {
33
- fullMessage += `${settings?.username} Welcome`;
34
- } else if (alertType.includes('subscriber')) {
35
- fullMessage += `${settings?.username} just subscribed!`;
36
- } else if (alertType.includes('bits')) {
37
- fullMessage += `${settings?.username} cheered ${settings?.amount ?? ''} bits`;
119
+ const settings = data.extraSettings || {};
120
+ let fullMessage = "";
121
+
122
+ if (alertType === "streamlabs-donation" || alertType === "streamelements-donation" || alertType === "lumiastream-donation") {
123
+ fullMessage = `${settings.username || "Someone"} just tipped ${settings.amount ?? data.dynamic?.value ?? ""} ${settings.currency ?? ""}`;
124
+ } else if (alertType === "twitch-follower" || alertType === "kick-follower") {
125
+ fullMessage = `${settings.username || "Someone"} is now following!`;
126
+ } else if (alertType === "twitch-subscriber" || alertType === "kick-subscriber") {
127
+ fullMessage = `${settings.username || "Someone"} just subscribed!`;
128
+ } else if (alertType === "twitch-bits") {
129
+ fullMessage = `${settings.username || "Someone"} cheered ${data.dynamic?.value ?? settings.amount ?? ""} bits`;
130
+ } else if (alertType === "twitch-raid") {
131
+ fullMessage = `${settings.username || "Someone"} raided with ${data.dynamic?.value ?? settings.viewers ?? 0} viewers`;
132
+ } else {
133
+ return;
38
134
  }
39
135
 
40
136
  if (settings?.message) fullMessage += ` They said ${settings?.message}`;
41
137
 
42
- // Display the alert inside the HTML container
43
- document.getElementById('alert-container').innerHTML = `<div class="message-container"><img src="${settings?.avatar}" id="avatar"/> ${fullMessage}</div>`;
138
+ alertContainer.textContent = "";
139
+ const messageEl = document.createElement("div");
140
+ messageEl.className = "message-container";
141
+
142
+ const avatarEl = document.createElement("img");
143
+ avatarEl.className = "avatar";
144
+ avatarEl.src = settings.avatar || "https://storage.lumiastream.com/placeholderUserIcon.png";
145
+ avatarEl.alt = "";
146
+
147
+ const textEl = document.createElement("span");
148
+ textEl.textContent = fullMessage;
149
+
150
+ messageEl.append(avatarEl, textEl);
151
+ alertContainer.appendChild(messageEl);
44
152
  });
45
153
  ```
46
154
 
@@ -96,7 +204,7 @@ Overlay.on('alert', (data) => {
96
204
  align-items: center;
97
205
  gap: 0.5rem;
98
206
  }
99
- #avatar {
207
+ .avatar {
100
208
  background: var(--secondary);
101
209
  border-radius: 100px;
102
210
  height: 32px;
@@ -123,15 +231,20 @@ Overlay.on('alert', (data) => {
123
231
  "label": "Text color:",
124
232
  "order": 3
125
233
  },
234
+ "messageBgColor": {
235
+ "type": "colorpicker",
236
+ "label": "Message background color:",
237
+ "order": 4
238
+ },
126
239
  "rounded": {
127
240
  "type": "input",
128
241
  "label": "Rounded corners:",
129
- "order": 4
242
+ "order": 5
130
243
  },
131
244
  "alertImage": {
132
245
  "type": "input",
133
246
  "label": "Alert image URL:",
134
- "order": 5
247
+ "order": 6
135
248
  }
136
249
  }
137
250
  ```
@@ -155,26 +268,52 @@ Overlay.on('alert', (data) => {
155
268
 
156
269
  ```js
157
270
  // Overlay.data is fetched from the sidebar input values
158
- const messageContainer = document.getElementById('container');
271
+ const messageContainer = document.getElementById("container");
272
+ const messagesContainer = document.getElementById("messages-container");
159
273
 
160
274
  // Pass data to css variable to use it easily inside css
161
- if (Overlay.data?.primaryColor) messageContainer.style.setProperty('--primary', Overlay.data?.primaryColor);
162
- if (Overlay.data?.backgroundColor) messageContainer.style.setProperty('--secondary', Overlay.data?.backgroundColor);
163
- if (Overlay.data?.messageBgColor) messageContainer.style.setProperty('--bg', Overlay.data?.messageBgColor);
164
- if (Overlay.data?.textColor) messageContainer.style.setProperty('--text', Overlay.data?.textColor);
165
- if (Overlay.data?.rounded) messageContainer.style.setProperty('--rounded', Overlay.data?.rounded);
275
+ if (Overlay.data?.primaryColor) messageContainer.style.setProperty("--primary", Overlay.data.primaryColor);
276
+ if (Overlay.data?.backgroundColor) messageContainer.style.setProperty("--secondary", Overlay.data.backgroundColor);
277
+ if (Overlay.data?.messageBgColor) messageContainer.style.setProperty("--bg", Overlay.data.messageBgColor);
278
+ if (Overlay.data?.textColor) messageContainer.style.setProperty("--text", Overlay.data.textColor);
279
+ if (Overlay.data?.rounded) messageContainer.style.setProperty("--rounded", Overlay.data.rounded);
166
280
 
167
281
  // Listen for chat messages
168
- Overlay.on('chat', (data) => {
169
- const origin = data.origin;
282
+ Overlay.on("chat", (data) => {
283
+ const messageEl = document.createElement("div");
284
+ messageEl.className = "message";
285
+
286
+ const originEl = document.createElement("span");
287
+ originEl.className = "origin";
288
+ originEl.textContent = `From ${data.origin}`;
170
289
 
171
- // Append the chat messages to the HTML container
172
- document.getElementById(
173
- 'messages-container',
174
- ).innerHTML += `<div class="message" id="message"><span id="origin">From ${origin}</span><img src="${data.avatar}" id="avatar"/><label id="username">${data.username}:</label><div id="content">${data.message}</div></div>`;
290
+ const avatarEl = document.createElement("img");
291
+ avatarEl.className = "avatar";
292
+ avatarEl.src = data.avatar || "https://storage.lumiastream.com/placeholderUserIcon.png";
293
+ avatarEl.alt = "";
294
+
295
+ const usernameEl = document.createElement("strong");
296
+ usernameEl.className = "username";
297
+ usernameEl.textContent = `${data.username}:`;
298
+
299
+ const contentEl = document.createElement("span");
300
+ contentEl.className = "content";
301
+ contentEl.textContent = data.message;
302
+
303
+ messageEl.append(originEl, avatarEl, usernameEl, contentEl);
304
+ messagesContainer.appendChild(messageEl);
175
305
  });
176
306
  ```
177
307
 
308
+ ### HTML
309
+
310
+ ```html
311
+ <div id="container">
312
+ <h1>Custom Chat Box</h1>
313
+ <div id="messages-container" class="messages-container"></div>
314
+ </div>
315
+ ```
316
+
178
317
  ### CSS Styling
179
318
 
180
319
  ```css
@@ -212,7 +351,7 @@ Overlay.on('chat', (data) => {
212
351
  background: var(--bg);
213
352
  border-radius: 10px;
214
353
  }
215
- #avatar {
354
+ .avatar {
216
355
  background: var(--secondary);
217
356
  border-radius: 100px;
218
357
  height: 32px;
@@ -288,15 +427,16 @@ async function() {
288
427
  const calcEl = document.getElementById('calc-container');
289
428
  const exprEl = document.getElementById('expression');
290
429
  const resultEl = document.getElementById('result');
430
+ const cfg = Overlay.data || {};
291
431
 
292
432
  // Apply colors from sidebar
293
- if (data.primaryColor) calcEl.style.setProperty('--primary', data.primaryColor);
294
- if (data.backgroundColor) calcEl.style.setProperty('--background', data.backgroundColor);
433
+ if (cfg.primaryColor) calcEl.style.setProperty('--primary', cfg.primaryColor);
434
+ if (cfg.backgroundColor) calcEl.style.setProperty('--background', cfg.backgroundColor);
295
435
 
296
436
  let tokens = [];
297
437
  let showingResult = false;
298
438
 
299
- const IDLE_MS = data.idleTimeout ?? 20000;
439
+ const IDLE_MS = cfg.idleTimeout ?? 20000;
300
440
  let idleTimer = null;
301
441
 
302
442
  function resetIdleTimer() {
@@ -309,7 +449,7 @@ function resetIdleTimer() {
309
449
  }
310
450
 
311
451
  function speak(text, opts = {}) {
312
- if (!data.tts) return;
452
+ if (!cfg.tts) return;
313
453
  const u = new SpeechSynthesisUtterance(text);
314
454
  Object.assign(u, opts); // voice, rate, pitch, lang, etc.
315
455
  window.speechSynthesis.cancel(); // stop anything currently talking
@@ -334,7 +474,14 @@ function verbalize(token) {
334
474
  }
335
475
 
336
476
  function render() {
337
- exprEl.innerHTML = tokens.map((t) => (/^[0-9.]+$/.test(t) ? `<span class="token number">${t}</span>` : `<span class="token op">${t}</span>`)).join(' ');
477
+ exprEl.textContent = '';
478
+ tokens.forEach((t, i) => {
479
+ if (i > 0) exprEl.appendChild(document.createTextNode(' '));
480
+ const span = document.createElement('span');
481
+ span.className = /^[0-9.]+$/.test(t) ? 'token number' : 'token op';
482
+ span.textContent = t;
483
+ exprEl.appendChild(span);
484
+ });
338
485
  }
339
486
 
340
487
  function evaluate() {
@@ -498,10 +645,11 @@ Overlay.on('overlaycontent', (data) => {
498
645
 
499
646
  ```json
500
647
  {
501
- "tts": true,
648
+ "tts": false,
649
+ "ttsVoice": "",
502
650
  "idleTimeout": 20000,
503
- "primaryColor": "#00000000",
504
- "backgroundColor": "#00000000"
651
+ "primaryColor": "#ffffff",
652
+ "backgroundColor": "#00000080"
505
653
  }
506
654
  ```
507
655
 
@@ -511,11 +659,15 @@ Overlay.on('overlaycontent', (data) => {
511
659
 
512
660
  ```js
513
661
  const die = document.getElementById('die');
514
- const HIDE_MS = data.hideDelay ?? 5000;
662
+ const cfg = Overlay.data || {};
663
+ const HIDE_MS = cfg.hideDelay ?? 5000;
515
664
  let hideT = null;
516
665
  let min = 1;
517
666
  let max = 6;
518
667
 
668
+ if (cfg.dotColor) die.style.setProperty('--dot-color', cfg.dotColor);
669
+ die.style.setProperty('--roll-ms', `${cfg.rollDuration ?? 900}ms`);
670
+
519
671
  function showDie() {
520
672
  die.classList.remove('hidden');
521
673
  clearTimeout(hideT);
@@ -537,7 +689,7 @@ Overlay.on('chat', (data) => {
537
689
  // reveal value after spin
538
690
  setTimeout(() => {
539
691
  die.textContent = v;
540
- }, data.rollDuration);
692
+ }, cfg.rollDuration ?? 900);
541
693
 
542
694
  // make it visible & schedule hide
543
695
  showDie();
@@ -906,13 +1058,15 @@ async function showPokedex(username = 'Someone') {
906
1058
 
907
1059
  dexMsgEl.textContent = list.length ? `${username} has caught ${list.length} / ${cfg.maxDex}` : `${username} hasn’t caught anything yet.`;
908
1060
 
909
- dexImgsEl.innerHTML = list
910
- .map((p) => {
911
- const slug = p.name.toLowerCase().replace(/♀/g, 'f').replace(/♂/g, 'm').replace(/[.'’]/g, '').replace(/\s+/g, '-');
912
- const src = `https://img.pokemondb.net/sprites/home/${p.shiny ? 'shiny' : 'normal'}/${slug}.png`;
913
- return `<img class="poke${p.shiny ? ' shiny' : ''}" src="${src}" alt="${p.name}">`;
914
- })
915
- .join('');
1061
+ dexImgsEl.textContent = '';
1062
+ list.forEach((p) => {
1063
+ const slug = p.name.toLowerCase().replace(/♀/g, 'f').replace(/♂/g, 'm').replace(/[.'’]/g, '').replace(/\s+/g, '-');
1064
+ const img = document.createElement('img');
1065
+ img.className = 'poke' + (p.shiny ? ' shiny' : '');
1066
+ img.src = `https://img.pokemondb.net/sprites/home/${p.shiny ? 'shiny' : 'normal'}/${slug}.png`;
1067
+ img.alt = p.name;
1068
+ dexImgsEl.appendChild(img);
1069
+ });
916
1070
 
917
1071
  modalDex.classList.remove('hidden');
918
1072
  setTimeout(() => modalDex.classList.add('hidden'), 10000);
@@ -925,17 +1079,25 @@ async function showLeaderboard() {
925
1079
  .sort((a, b) => b[1].total - a[1].total)
926
1080
  .slice(0, 10);
927
1081
 
928
- boardList.innerHTML = list.length
929
- ? list
930
- .map(([u, d]) => {
931
- const tip = d.list.map((p) => (p.shiny ? `${p.name} ⭐` : p.name)).join(', ');
932
- return `<li>
933
- <span class="user" title="${tip}">${u}</span>
934
- <span>${d.total}${d.shiny ? ` ⭐${d.shiny}` : ''}</span>
935
- </li>`;
936
- })
937
- .join('')
938
- : '<li>No catches yet</li>';
1082
+ boardList.textContent = '';
1083
+ if (!list.length) {
1084
+ const li = document.createElement('li');
1085
+ li.textContent = 'No catches yet';
1086
+ boardList.appendChild(li);
1087
+ } else {
1088
+ list.forEach(([u, d]) => {
1089
+ const tip = d.list.map((p) => (p.shiny ? `${p.name} ⭐` : p.name)).join(', ');
1090
+ const li = document.createElement('li');
1091
+ const userSpan = document.createElement('span');
1092
+ userSpan.className = 'user';
1093
+ userSpan.title = tip;
1094
+ userSpan.textContent = u;
1095
+ const totalSpan = document.createElement('span');
1096
+ totalSpan.textContent = d.total + (d.shiny ? ` ⭐${d.shiny}` : '');
1097
+ li.append(userSpan, totalSpan);
1098
+ boardList.appendChild(li);
1099
+ });
1100
+ }
939
1101
 
940
1102
  boardList.classList.remove('hidden');
941
1103
 
@@ -1343,6 +1505,12 @@ body {
1343
1505
  "order": 7,
1344
1506
  "value": 60
1345
1507
  },
1508
+ "msgMs": {
1509
+ "type": "number",
1510
+ "label": "Capture message duration (ms)",
1511
+ "order": 8,
1512
+ "value": 7000
1513
+ },
1346
1514
  "idleSec": {
1347
1515
  "type": "number",
1348
1516
  "label": "Flee time (s)",
@@ -1392,8 +1560,9 @@ body {
1392
1560
  "fleePct": 15,
1393
1561
  "idleSec": 30,
1394
1562
  "catchPct": 40,
1395
- "maxSpawn": 120,
1396
- "minSpawn": 30,
1563
+ "maxSpawn": 60,
1564
+ "minSpawn": 40,
1565
+ "msgMs": 7000,
1397
1566
  "shinyRate": 2,
1398
1567
  "disableChat": false,
1399
1568
  "catchCommand": "!catch",
@@ -2308,3 +2477,434 @@ html, body, #board {
2308
2477
  "twitchPointsCommand": "art"
2309
2478
  }
2310
2479
  ```
2480
+
2481
+ ## HFX Listener Banner
2482
+
2483
+ Reacts to Lumia HFX triggers. Flashes a banner showing who triggered the HFX, which command, and their message. Uses `Overlay.on("hfx", ...)` — the handler receives the raw HFX payload (no `event.detail`).
2484
+
2485
+ ### HTML
2486
+
2487
+ ```html
2488
+ <div id="hfx-banner" class="hidden">
2489
+ <img id="hfx-avatar" alt="" />
2490
+ <div class="text">
2491
+ <div id="hfx-user"></div>
2492
+ <div id="hfx-command"></div>
2493
+ <div id="hfx-message"></div>
2494
+ </div>
2495
+ </div>
2496
+ ```
2497
+
2498
+ ### CSS
2499
+
2500
+ ```css
2501
+ body {
2502
+ background: transparent;
2503
+ font-family: "{{font}}";
2504
+ color: {{textColor}};
2505
+ }
2506
+ #hfx-banner {
2507
+ position: absolute;
2508
+ bottom: 40px;
2509
+ left: 40px;
2510
+ display: flex;
2511
+ align-items: center;
2512
+ gap: 16px;
2513
+ padding: 16px 24px;
2514
+ background: {{bgColor}};
2515
+ border-radius: 16px;
2516
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
2517
+ transform: translateX(-120%);
2518
+ transition: transform 400ms cubic-bezier(0.2, 0.9, 0.3, 1);
2519
+ }
2520
+ #hfx-banner.show {
2521
+ transform: translateX(0);
2522
+ }
2523
+ #hfx-banner.hidden {
2524
+ display: none;
2525
+ }
2526
+ #hfx-avatar {
2527
+ width: 64px;
2528
+ height: 64px;
2529
+ border-radius: 50%;
2530
+ background: #222;
2531
+ object-fit: cover;
2532
+ }
2533
+ .text {
2534
+ display: flex;
2535
+ flex-direction: column;
2536
+ gap: 2px;
2537
+ font-size: {{fontSize}}px;
2538
+ }
2539
+ #hfx-user {
2540
+ font-weight: 700;
2541
+ }
2542
+ #hfx-command {
2543
+ opacity: 0.8;
2544
+ font-size: 0.8em;
2545
+ }
2546
+ #hfx-message {
2547
+ opacity: 0.9;
2548
+ font-style: italic;
2549
+ }
2550
+ ```
2551
+
2552
+ ### JS
2553
+
2554
+ ```js
2555
+ const banner = document.getElementById("hfx-banner");
2556
+ const avatarEl = document.getElementById("hfx-avatar");
2557
+ const userEl = document.getElementById("hfx-user");
2558
+ const commandEl = document.getElementById("hfx-command");
2559
+ const messageEl = document.getElementById("hfx-message");
2560
+ let hideTimer = null;
2561
+
2562
+ function show(data) {
2563
+ avatarEl.src = data.avatar || "https://storage.lumiastream.com/placeholderUserIcon.png";
2564
+ userEl.textContent = data.username || "Someone";
2565
+ commandEl.textContent = data.command ? `triggered ${data.command}` : "triggered an HFX";
2566
+ messageEl.textContent = data.message || "";
2567
+
2568
+ banner.classList.remove("hidden");
2569
+ // allow the DOM to register the hidden→visible transition
2570
+ requestAnimationFrame(() => banner.classList.add("show"));
2571
+
2572
+ clearTimeout(hideTimer);
2573
+ const holdMs = Math.max(2000, Number(data.duration) || Number(Overlay.data?.holdMs) || 5000);
2574
+ hideTimer = setTimeout(() => {
2575
+ banner.classList.remove("show");
2576
+ setTimeout(() => banner.classList.add("hidden"), 500);
2577
+ }, holdMs);
2578
+ }
2579
+
2580
+ Overlay.on("hfx", (data) => {
2581
+ show(data);
2582
+ });
2583
+ ```
2584
+
2585
+ ### Configs
2586
+
2587
+ ```json
2588
+ {
2589
+ "bgColor": {
2590
+ "type": "colorpicker",
2591
+ "label": "Banner background",
2592
+ "order": 1,
2593
+ "value": "#1a1a2ecc"
2594
+ },
2595
+ "textColor": {
2596
+ "type": "colorpicker",
2597
+ "label": "Text color",
2598
+ "order": 2,
2599
+ "value": "#ffffff"
2600
+ },
2601
+ "font": {
2602
+ "type": "fontpicker",
2603
+ "label": "Font",
2604
+ "order": 3,
2605
+ "value": "Inter"
2606
+ },
2607
+ "fontSize": {
2608
+ "type": "slider",
2609
+ "label": "Font size",
2610
+ "order": 4,
2611
+ "options": { "min": 12, "max": 40, "step": 2, "suffix": "px" },
2612
+ "value": 20
2613
+ },
2614
+ "holdMs": {
2615
+ "type": "number",
2616
+ "label": "Display time (ms) — used when HFX duration is missing",
2617
+ "order": 5,
2618
+ "value": 5000
2619
+ }
2620
+ }
2621
+ ```
2622
+
2623
+ ### Data
2624
+
2625
+ ```json
2626
+ {
2627
+ "bgColor": "#1a1a2ecc",
2628
+ "textColor": "#ffffff",
2629
+ "font": "Inter",
2630
+ "fontSize": 20,
2631
+ "holdMs": 5000
2632
+ }
2633
+ ```
2634
+
2635
+ ## Virtual Light Monitor
2636
+
2637
+ Shows an on-screen representation of a Lumia virtual light: a colored dot that matches the current color, brightness, and power state. Uses `Overlay.on("virtuallight", ...)`. Also demonstrates the first-load storage pattern so the last-known color persists across overlay reloads.
2638
+
2639
+ ### HTML
2640
+
2641
+ ```html
2642
+ <div id="wrap">
2643
+ <div id="dot"></div>
2644
+ <div id="label"></div>
2645
+ </div>
2646
+ ```
2647
+
2648
+ ### CSS
2649
+
2650
+ ```css
2651
+ body {
2652
+ background: transparent;
2653
+ font-family: "{{font}}";
2654
+ color: #ffffff;
2655
+ }
2656
+ #wrap {
2657
+ display: flex;
2658
+ align-items: center;
2659
+ gap: 14px;
2660
+ padding: 14px 20px;
2661
+ background: rgba(0, 0, 0, 0.55);
2662
+ border-radius: 999px;
2663
+ width: fit-content;
2664
+ }
2665
+ #dot {
2666
+ width: 36px;
2667
+ height: 36px;
2668
+ border-radius: 50%;
2669
+ background: #000;
2670
+ box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.12);
2671
+ transition: background 200ms linear, opacity 200ms linear, transform 200ms ease;
2672
+ }
2673
+ #dot.off {
2674
+ opacity: 0.25;
2675
+ transform: scale(0.85);
2676
+ }
2677
+ #label {
2678
+ font-size: 16px;
2679
+ white-space: nowrap;
2680
+ }
2681
+ ```
2682
+
2683
+ ### JS
2684
+
2685
+ ```js
2686
+ const dot = document.getElementById("dot");
2687
+ const label = document.getElementById("label");
2688
+ const STORAGE_KEY = "last_light_state";
2689
+
2690
+ // Default state (first load)
2691
+ let state = {
2692
+ uuid: "",
2693
+ r: 0,
2694
+ g: 0,
2695
+ b: 0,
2696
+ brightness: 100,
2697
+ power: true,
2698
+ };
2699
+
2700
+ // Seed storage on first load so we never see the red "no value" toast
2701
+ const saved = await Overlay.getStorage(STORAGE_KEY);
2702
+ if (saved == null) {
2703
+ await Overlay.saveStorage(STORAGE_KEY, state);
2704
+ } else {
2705
+ state = saved;
2706
+ }
2707
+ render();
2708
+
2709
+ function render() {
2710
+ const { r, g, b, brightness, power, uuid } = state;
2711
+ dot.style.background = `rgb(${r}, ${g}, ${b})`;
2712
+ dot.classList.toggle("off", power === false);
2713
+ const hex = `#${[r, g, b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
2714
+ const name = uuid || "No light yet";
2715
+ label.textContent = power === false ? `${name} — off` : `${name} — ${hex} @ ${brightness}%`;
2716
+ }
2717
+
2718
+ Overlay.on("virtuallight", async (data) => {
2719
+ state = {
2720
+ uuid: data.uuid || state.uuid,
2721
+ r: data.color?.r ?? state.r,
2722
+ g: data.color?.g ?? state.g,
2723
+ b: data.color?.b ?? state.b,
2724
+ brightness: data.brightness ?? state.brightness,
2725
+ power: data.power !== false,
2726
+ };
2727
+ render();
2728
+ await Overlay.saveStorage(STORAGE_KEY, state);
2729
+ });
2730
+ ```
2731
+
2732
+ ### Configs
2733
+
2734
+ ```json
2735
+ {
2736
+ "font": {
2737
+ "type": "fontpicker",
2738
+ "label": "Font",
2739
+ "order": 1,
2740
+ "value": "Inter"
2741
+ }
2742
+ }
2743
+ ```
2744
+
2745
+ ### Data
2746
+
2747
+ ```json
2748
+ {
2749
+ "font": "Inter"
2750
+ }
2751
+ ```
2752
+
2753
+ ## Loyalty Points Leaderboard
2754
+
2755
+ Uses `Overlay.getLoyaltyPoints` + `Overlay.addLoyaltyPoints`. Chat commands:
2756
+
2757
+ - `!points` — the user's current balance is posted back to chat via `Overlay.chatbot`.
2758
+ - `!give @user <n>` — transfer N points from the sender to `@user` (moderator-only).
2759
+
2760
+ Also renders a top-10 leaderboard from a locally cached list of users who have been seen in chat (loyalty points themselves are managed by Lumia — we just display the cache). Cache is persisted with `Overlay.saveStorage`.
2761
+
2762
+ ### HTML
2763
+
2764
+ ```html
2765
+ <div id="board">
2766
+ <h2>Top Loyalty</h2>
2767
+ <ol id="list"></ol>
2768
+ </div>
2769
+ ```
2770
+
2771
+ ### CSS
2772
+
2773
+ ```css
2774
+ body {
2775
+ background: transparent;
2776
+ font-family: "{{font}}";
2777
+ color: #ffffff;
2778
+ }
2779
+ #board {
2780
+ width: 360px;
2781
+ padding: 20px 24px;
2782
+ background: rgba(10, 10, 20, 0.85);
2783
+ border-radius: 16px;
2784
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
2785
+ }
2786
+ #board h2 {
2787
+ margin: 0 0 12px;
2788
+ font-size: 20px;
2789
+ color: {{accentColor}};
2790
+ }
2791
+ #list {
2792
+ margin: 0;
2793
+ padding: 0 0 0 24px;
2794
+ font-size: 16px;
2795
+ line-height: 1.6;
2796
+ }
2797
+ #list li .amount {
2798
+ float: right;
2799
+ font-weight: 700;
2800
+ color: {{accentColor}};
2801
+ }
2802
+ ```
2803
+
2804
+ ### JS
2805
+
2806
+ ```js
2807
+ const listEl = document.getElementById("list");
2808
+ const STORAGE_KEY = "loyalty_seen_users";
2809
+
2810
+ // first-load seed
2811
+ let seen = await Overlay.getStorage(STORAGE_KEY);
2812
+ if (seen == null) {
2813
+ seen = [];
2814
+ await Overlay.saveStorage(STORAGE_KEY, seen);
2815
+ }
2816
+
2817
+ async function refresh() {
2818
+ const rows = [];
2819
+ for (const u of seen) {
2820
+ const pts = Number(await Overlay.getLoyaltyPoints({ username: u.username, platform: u.platform })) || 0;
2821
+ rows.push({ ...u, pts });
2822
+ }
2823
+ rows.sort((a, b) => b.pts - a.pts);
2824
+
2825
+ listEl.textContent = "";
2826
+ rows.slice(0, 10).forEach((r) => {
2827
+ const li = document.createElement("li");
2828
+ const name = document.createElement("span");
2829
+ name.textContent = r.username;
2830
+ const amt = document.createElement("span");
2831
+ amt.className = "amount";
2832
+ amt.textContent = r.pts.toLocaleString();
2833
+ li.append(name, amt);
2834
+ listEl.appendChild(li);
2835
+ });
2836
+ }
2837
+
2838
+ async function remember(username, platform) {
2839
+ if (!username || !platform) return;
2840
+ const existing = seen.find((u) => u.username === username && u.platform === platform);
2841
+ if (existing) return;
2842
+ seen.push({ username, platform });
2843
+ await Overlay.saveStorage(STORAGE_KEY, seen);
2844
+ }
2845
+
2846
+ Overlay.on("chat", async (data) => {
2847
+ const msg = (data.message || "").trim();
2848
+ const username = data.username;
2849
+ const platform = data.platform || "twitch";
2850
+ const isMod = data.userLevels?.mod || data.userLevels?.isSelf;
2851
+
2852
+ await remember(username, platform);
2853
+
2854
+ // !points — look up caller's balance
2855
+ if (msg.toLowerCase() === "!points") {
2856
+ const pts = Number(await Overlay.getLoyaltyPoints({ username, platform })) || 0;
2857
+ await Overlay.chatbot({ message: `${username} has ${pts} points`, platform });
2858
+ await refresh();
2859
+ return;
2860
+ }
2861
+
2862
+ // !give @user 50 — moderator transfer
2863
+ if (msg.toLowerCase().startsWith("!give ") && isMod) {
2864
+ const parts = msg.split(/\s+/);
2865
+ const target = (parts[1] || "").replace(/^@/, "");
2866
+ const amount = Math.max(0, Math.floor(Number(parts[2]) || 0));
2867
+ if (!target || !amount) {
2868
+ await Overlay.chatbot({ message: "Usage: !give @user 50", platform });
2869
+ return;
2870
+ }
2871
+ await Overlay.addLoyaltyPoints({ value: -amount, username, platform });
2872
+ await Overlay.addLoyaltyPoints({ value: amount, username: target, platform });
2873
+ await remember(target, platform);
2874
+ await Overlay.chatbot({ message: `${username} gave ${amount} points to ${target}`, platform });
2875
+ await refresh();
2876
+ }
2877
+ });
2878
+
2879
+ // Refresh on load and every 30s so balances stay current
2880
+ await refresh();
2881
+ setInterval(refresh, 30000);
2882
+ ```
2883
+
2884
+ ### Configs
2885
+
2886
+ ```json
2887
+ {
2888
+ "accentColor": {
2889
+ "type": "colorpicker",
2890
+ "label": "Accent color",
2891
+ "order": 1,
2892
+ "value": "#ffcc00"
2893
+ },
2894
+ "font": {
2895
+ "type": "fontpicker",
2896
+ "label": "Font",
2897
+ "order": 2,
2898
+ "value": "Inter"
2899
+ }
2900
+ }
2901
+ ```
2902
+
2903
+ ### Data
2904
+
2905
+ ```json
2906
+ {
2907
+ "accentColor": "#ffcc00",
2908
+ "font": "Inter"
2909
+ }
2910
+ ```