this repo has no description

redesign

+560 -144
assets/pokeballs/dusk-ball.png

This is a binary file and will not be displayed.

assets/pokeballs/great-ball.png

This is a binary file and will not be displayed.

assets/pokeballs/heal-ball.png

This is a binary file and will not be displayed.

assets/pokeballs/luxury-ball.png

This is a binary file and will not be displayed.

assets/pokeballs/master-ball.png

This is a binary file and will not be displayed.

assets/pokeballs/nest-ball.png

This is a binary file and will not be displayed.

assets/pokeballs/poke-ball.png

This is a binary file and will not be displayed.

assets/pokeballs/premier-ball.png

This is a binary file and will not be displayed.

assets/pokeballs/quick-ball.png

This is a binary file and will not be displayed.

assets/pokeballs/repeat-ball.png

This is a binary file and will not be displayed.

assets/pokeballs/timer-ball.png

This is a binary file and will not be displayed.

assets/pokeballs/ultra-ball.png

This is a binary file and will not be displayed.

+560 -144
overlay.html
··· 6 <title>Stream Overlay</title> 7 <style> 8 :root { 9 - --bg-chat: rgba(8, 12, 18, 0.54); 10 - --bg-queue: rgba(4, 8, 14, 0.64); 11 - --border: rgba(159, 181, 205, 0.28); 12 - --text: #f4f8fc; 13 - --muted: #9db2c6; 14 - --active: #79ffe1; 15 - --queued: #9ad4ff; 16 - --done: #7c8ba2; 17 - --error: #ff7e7e; 18 } 19 20 * { ··· 27 height: 100%; 28 margin: 0; 29 overflow: hidden; 30 - background: transparent; 31 color: var(--text); 32 font-family: "Avenir Next", "Segoe UI", sans-serif; 33 } 34 35 .overlay { ··· 37 width: 100%; 38 height: 100%; 39 pointer-events: none; 40 } 41 42 .chat-panel { 43 position: absolute; 44 - top: 2vh; 45 - right: 1.2vw; 46 width: min(32vw, 460px); 47 height: 76vh; 48 display: flex; 49 flex-direction: column; 50 gap: 8px; 51 padding: 14px; 52 - border: 1px solid var(--border); 53 - border-radius: 18px; 54 - background: 55 - radial-gradient(circle at 20% 0%, rgba(121, 255, 225, 0.13), transparent 58%), 56 - var(--bg-chat); 57 - backdrop-filter: blur(8px); 58 } 59 60 .panel-title { 61 text-transform: uppercase; 62 - letter-spacing: 0.12em; 63 font-size: 12px; 64 - color: var(--muted); 65 margin: 0 0 6px; 66 } 67 68 .panel-head { ··· 76 font-size: 11px; 77 text-transform: uppercase; 78 letter-spacing: 0.08em; 79 - border: 1px solid rgba(157, 178, 198, 0.45); 80 border-radius: 999px; 81 padding: 5px 10px; 82 - background: rgba(10, 17, 28, 0.55); 83 color: var(--muted); 84 white-space: nowrap; 85 } 86 87 .ability-badge.enabled { 88 - border-color: rgba(121, 255, 225, 0.8); 89 - color: var(--active); 90 } 91 92 .ability-badge.disabled { 93 - border-color: rgba(255, 126, 126, 0.7); 94 color: var(--error); 95 } 96 97 .chat-list { ··· 112 } 113 114 .chat-item { 115 - border: 1px solid rgba(157, 178, 198, 0.26); 116 - border-radius: 12px; 117 padding: 9px 11px; 118 - background: rgba(10, 17, 28, 0.55); 119 animation: enter 220ms ease-out; 120 display: grid; 121 grid-template-columns: 34px 1fr; 122 gap: 9px; 123 } 124 125 .avatar { ··· 127 height: 34px; 128 border-radius: 50%; 129 object-fit: cover; 130 - border: 1px solid rgba(176, 236, 255, 0.45); 131 - background: rgba(157, 178, 198, 0.25); 132 } 133 134 .avatar.fallback { ··· 136 place-items: center; 137 font-size: 12px; 138 font-weight: 700; 139 - color: #d9f5ff; 140 } 141 142 .chat-user { 143 - font-size: 12px; 144 - font-weight: 650; 145 - color: #b0ecff; 146 } 147 148 .chat-handle { 149 - font-size: 11px; 150 color: var(--muted); 151 } 152 ··· 160 161 .queue-panel { 162 position: absolute; 163 - left: 1.2vw; 164 - right: 1.2vw; 165 - bottom: 2vh; 166 display: flex; 167 flex-direction: column; 168 gap: 10px; 169 - padding: 12px 14px 14px; 170 - border: 1px solid var(--border); 171 - border-radius: 16px; 172 - background: 173 - linear-gradient(90deg, rgba(122, 165, 255, 0.15), rgba(121, 255, 225, 0.1)), 174 - var(--bg-queue); 175 - backdrop-filter: blur(9px); 176 } 177 178 .queue-list { ··· 180 padding: 0; 181 list-style: none; 182 display: flex; 183 - gap: 10px; 184 overflow-x: auto; 185 scrollbar-width: none; 186 } ··· 190 } 191 192 .queue-item { 193 - min-width: 150px; 194 - padding: 9px 10px; 195 - border-radius: 12px; 196 - border: 1px solid rgba(154, 212, 255, 0.4); 197 - background: rgba(9, 15, 24, 0.62); 198 - display: grid; 199 - gap: 2px; 200 - transition: transform 140ms ease, border-color 140ms ease, box-shadow 140ms ease; 201 - } 202 - 203 - .queue-head { 204 - display: grid; 205 - grid-template-columns: 22px 1fr; 206 align-items: center; 207 gap: 7px; 208 - } 209 - 210 - .queue-avatar { 211 - width: 22px; 212 - height: 22px; 213 - border-radius: 50%; 214 - object-fit: cover; 215 - border: 1px solid rgba(176, 236, 255, 0.5); 216 - background: rgba(157, 178, 198, 0.3); 217 } 218 219 - .queue-avatar.fallback { 220 - display: grid; 221 - place-items: center; 222 - font-size: 10px; 223 font-weight: 700; 224 - color: #d9f5ff; 225 } 226 227 - .queue-item .cmd { 228 - font-size: 20px; 229 line-height: 1; 230 - font-weight: 700; 231 - letter-spacing: 0.04em; 232 } 233 234 - .queue-item .user { 235 - color: var(--muted); 236 - font-size: 12px; 237 } 238 239 - .queue-item .handle { 240 - color: #b0ecff; 241 - font-size: 11px; 242 } 243 244 - .queue-item .state { 245 - font-size: 11px; 246 - text-transform: uppercase; 247 - letter-spacing: 0.11em; 248 } 249 250 .queue-item.queued { 251 - border-color: rgba(154, 212, 255, 0.42); 252 } 253 254 .queue-item.queued .state { ··· 257 258 .queue-item.active { 259 border-color: var(--active); 260 - box-shadow: 0 0 0 2px rgba(121, 255, 225, 0.26), 0 0 28px rgba(121, 255, 225, 0.28); 261 - transform: translateY(-2px) scale(1.03); 262 - } 263 - 264 - .queue-item.active .state { 265 - color: var(--active); 266 } 267 268 .queue-item.done { 269 - opacity: 0.65; 270 - border-color: rgba(124, 139, 162, 0.42); 271 - } 272 - 273 - .queue-item.done .state { 274 - color: var(--done); 275 } 276 277 .queue-item.error { 278 border-color: rgba(255, 126, 126, 0.6); 279 - } 280 - 281 - .queue-item.error .state { 282 color: var(--error); 283 } 284 285 .status { 286 font-size: 12px; 287 color: var(--muted); 288 } 289 290 @keyframes enter { ··· 308 font-size: 15px; 309 } 310 } 311 </style> 312 </head> 313 <body> 314 <div class="overlay"> 315 <section class="chat-panel"> 316 - <h2 class="panel-title">Live Chat</h2> 317 <ul id="chat-list" class="chat-list"></ul> 318 </section> 319 320 <section class="queue-panel"> 321 - <div class="panel-head"> 322 - <h2 class="panel-title">Command Queue</h2> 323 <div id="spam-ability" class="ability-badge">Spam: Unknown</div> 324 </div> 325 - <ul id="queue-list" class="queue-list"></ul> 326 - <div id="status" class="status">Connecting...</div> 327 </section> 328 </div> 329 330 <script> 331 const chatList = document.getElementById("chat-list"); 332 const queueList = document.getElementById("queue-list"); 333 const statusEl = document.getElementById("status"); 334 const spamAbilityEl = document.getElementById("spam-ability"); 335 const query = new URLSearchParams(window.location.search); 336 const chatMode = query.get("chat") || "non_commands"; 337 const chatNodes = new Map(); 338 const queueNodes = new Map(); 339 340 function initial(text) { 341 const trimmed = (text || "").trim(); ··· 427 function patchChatNode(li, msg) { 428 setAvatar(li._avatarWrap, msg.avatarUrl, msg.user, "avatar", () => scheduleStickToEnd(chatList)); 429 li._user.textContent = msg.user || ""; 430 - li._handle.textContent = msg.handle ? `@${msg.handle.replace(/^@/, "")}` : shortDid(msg.did); 431 li._text.textContent = msg.text || ""; 432 } 433 ··· 435 const li = document.createElement("li"); 436 li.className = "queue-item"; 437 438 - const head = document.createElement("div"); 439 - head.className = "queue-head"; 440 - const avatarWrap = document.createElement("div"); 441 - avatarWrap.className = "queue-avatar-slot"; 442 - const handle = document.createElement("span"); 443 - handle.className = "handle"; 444 - head.append(avatarWrap, handle); 445 - 446 const cmd = document.createElement("span"); 447 - cmd.className = "cmd"; 448 - const user = document.createElement("span"); 449 - user.className = "user"; 450 - const state = document.createElement("span"); 451 - state.className = "state"; 452 453 - li.append(head, cmd, user, state); 454 - li._avatarWrap = avatarWrap; 455 - li._handle = handle; 456 - li._cmd = cmd; 457 li._user = user; 458 - li._state = state; 459 return li; 460 } 461 462 - function patchQueueNode(li, item, activeCommandId) { 463 - const activeState = activeCommandId === item.id ? "active" : item.status; 464 - li.className = `queue-item ${activeState}`; 465 - setAvatar(li._avatarWrap, item.avatarUrl, item.user, "queue-avatar", () => 466 - scheduleStickToEnd(queueList, true), 467 ); 468 - li._handle.textContent = item.handle ? `@${item.handle.replace(/^@/, "")}` : shortDid(item.did); 469 - li._cmd.textContent = (item.command || "").toUpperCase(); 470 - li._user.textContent = shortDid(item.did); 471 - li._state.textContent = item.status || ""; 472 } 473 474 function reconcileList(listEl, nodesById, items, patchNode, createNode, ...patchArgs) { ··· 505 reconcileList(chatList, chatNodes, filterChat(state.chat), patchChatNode, createChatNode); 506 scheduleStickToEnd(chatList); 507 508 - reconcileList( 509 - queueList, 510 - queueNodes, 511 - state.queue, 512 - patchQueueNode, 513 - createQueueNode, 514 - state.activeCommandId, 515 - ); 516 scheduleStickToEnd(queueList, true); 517 518 if (spamAbilityEl && state.spamAbility) { ··· 520 spamAbilityEl.classList.toggle("enabled", enabled); 521 spamAbilityEl.classList.toggle("disabled", !enabled); 522 spamAbilityEl.textContent = `Spam: ${enabled ? "ENABLED" : "DISABLED"} (${state.spamAbility.uniqueChatters}/${state.spamAbility.threshold} chatters, ${state.spamAbility.windowMinutes}m)`; 523 } 524 } 525 526 const events = new EventSource("/events");
··· 6 <title>Stream Overlay</title> 7 <style> 8 :root { 9 + --page-bg: #dff0ff; 10 + --bg-chat: rgba(255, 252, 236, 0.92); 11 + --bg-queue: rgba(255, 255, 255, 0.93); 12 + --border: rgba(44, 65, 122, 0.28); 13 + --text: #24315f; 14 + --muted: #5267a0; 15 + --active: #ff5252; 16 + --queued: #3f7bf7; 17 + --done: #8f9fc8; 18 + --error: #d64545; 19 + --yellow: #ffd84b; 20 + --blue: #3f7bf7; 21 + --red: #ff5252; 22 + --cream: #fff9de; 23 + --panel-shadow: rgba(44, 65, 122, 0.16); 24 + --panel-item-bg: rgba(255, 255, 255, 0.9); 25 + --queue-pill-bg: #fff9de; 26 + --queue-command-bg: #ffeaea; 27 + --queue-command-text: #a32626; 28 + --queue-user-text: #2b3f74; 29 + --overlay-dot: rgba(63, 123, 247, 0.1); 30 + --overlay-dot-strong: rgba(255, 216, 75, 0.2); 31 + --ability-enabled-bg: #e7ffe8; 32 + --ability-enabled-text: #1f7a2f; 33 + --ability-enabled-border: rgba(53, 176, 78, 0.82); 34 + --ability-disabled-bg: #fff2f2; 35 + --ability-disabled-border: rgba(214, 69, 69, 0.7); 36 + --orb-outline: rgba(20, 32, 70, 0.22); 37 + --orb-band: rgba(26, 36, 74, 0.7); 38 + --orb-button: #ffffff; 39 + --orb-button-ring: rgba(26, 36, 74, 0.42); 40 + --orb-label-bg: rgba(255, 255, 255, 0.86); 41 + --orb-label-text: #2a3970; 42 + --orb-opacity: 0.42; 43 + --frame-radius: 20px; 44 + --panel-offset-x: 1.2vw; 45 + --panel-offset-y: 2vh; 46 + } 47 + 48 + :root[data-theme="night"] { 49 + --page-bg: #101a35; 50 + --bg-chat: rgba(22, 33, 66, 0.9); 51 + --bg-queue: rgba(20, 29, 56, 0.92); 52 + --border: rgba(132, 162, 255, 0.32); 53 + --text: #e8efff; 54 + --muted: #afc3f4; 55 + --active: #ff7a7a; 56 + --queued: #86b2ff; 57 + --done: #7788b0; 58 + --error: #ff9a9a; 59 + --cream: #23325e; 60 + --panel-shadow: rgba(6, 10, 24, 0.4); 61 + --panel-item-bg: rgba(27, 39, 74, 0.9); 62 + --queue-pill-bg: #1f2e58; 63 + --queue-command-bg: #3c2a4f; 64 + --queue-command-text: #ffd0d0; 65 + --queue-user-text: #e4ecff; 66 + --overlay-dot: rgba(134, 178, 255, 0.08); 67 + --overlay-dot-strong: rgba(255, 237, 143, 0.12); 68 + --ability-enabled-bg: #213d2b; 69 + --ability-enabled-text: #b8ffca; 70 + --ability-enabled-border: rgba(104, 230, 138, 0.78); 71 + --ability-disabled-bg: #3c2630; 72 + --ability-disabled-border: rgba(255, 144, 144, 0.72); 73 + --orb-outline: rgba(186, 204, 255, 0.28); 74 + --orb-band: rgba(209, 222, 255, 0.55); 75 + --orb-button: #dce7ff; 76 + --orb-button-ring: rgba(186, 204, 255, 0.46); 77 + --orb-label-bg: rgba(20, 30, 58, 0.78); 78 + --orb-label-text: #d5e4ff; 79 + --orb-opacity: 0.32; 80 + --frame-radius: 20px; 81 + --panel-offset-x: 1.2vw; 82 + --panel-offset-y: 2vh; 83 } 84 85 * { ··· 92 height: 100%; 93 margin: 0; 94 overflow: hidden; 95 + background: var(--page-bg); 96 color: var(--text); 97 font-family: "Avenir Next", "Segoe UI", sans-serif; 98 + transition: background-color 280ms ease, color 280ms ease; 99 } 100 101 .overlay { ··· 103 width: 100%; 104 height: 100%; 105 pointer-events: none; 106 + overflow: hidden; 107 + } 108 + 109 + .overlay::after { 110 + content: ""; 111 + position: absolute; 112 + inset: 0; 113 + pointer-events: none; 114 + } 115 + 116 + .overlay::after { 117 + border: 12px solid rgba(255, 255, 255, 0.18); 118 + border-radius: var(--frame-radius); 119 + left: var(--panel-offset-x); 120 + right: var(--panel-offset-x); 121 + top: var(--panel-offset-y); 122 + bottom: var(--panel-offset-y); 123 + opacity: 0.4; 124 + z-index: 0; 125 + } 126 + 127 + .orb-field { 128 + position: absolute; 129 + inset: 0; 130 + z-index: 1; 131 + pointer-events: none; 132 + } 133 + 134 + .ball-orb { 135 + position: absolute; 136 + width: 84px; 137 + height: 84px; 138 + border-radius: 50%; 139 + overflow: visible; 140 + opacity: var(--orb-opacity); 141 + transform: scale(var(--orb-scale, 1)); 142 + will-change: left, top, transform; 143 + } 144 + .ball-sprite { 145 + width: 100%; 146 + height: 100%; 147 + object-fit: contain; 148 + image-rendering: pixelated; 149 + filter: drop-shadow(0 5px 10px rgba(10, 18, 36, 0.35)); 150 + user-select: none; 151 } 152 153 .chat-panel { 154 position: absolute; 155 + top: var(--panel-offset-y); 156 + right: var(--panel-offset-x); 157 width: min(32vw, 460px); 158 height: 76vh; 159 display: flex; 160 flex-direction: column; 161 gap: 8px; 162 padding: 14px; 163 + border: 2px solid var(--border); 164 + border-radius: var(--frame-radius); 165 + background: var(--bg-chat); 166 + box-shadow: 0 10px 24px var(--panel-shadow); 167 + z-index: 2; 168 + transition: background-color 280ms ease, border-color 280ms ease, box-shadow 280ms ease; 169 } 170 171 .panel-title { 172 text-transform: uppercase; 173 + letter-spacing: 0.08em; 174 font-size: 12px; 175 + color: var(--blue); 176 margin: 0 0 6px; 177 + font-weight: 800; 178 } 179 180 .panel-head { ··· 188 font-size: 11px; 189 text-transform: uppercase; 190 letter-spacing: 0.08em; 191 + border: 2px solid rgba(63, 123, 247, 0.5); 192 border-radius: 999px; 193 padding: 5px 10px; 194 + background: var(--cream); 195 color: var(--muted); 196 white-space: nowrap; 197 + font-weight: 700; 198 + transition: background-color 280ms ease, border-color 280ms ease, color 280ms ease; 199 } 200 201 .ability-badge.enabled { 202 + border-color: var(--ability-enabled-border); 203 + color: var(--ability-enabled-text); 204 + background: var(--ability-enabled-bg); 205 } 206 207 .ability-badge.disabled { 208 + border-color: var(--ability-disabled-border); 209 color: var(--error); 210 + background: var(--ability-disabled-bg); 211 } 212 213 .chat-list { ··· 228 } 229 230 .chat-item { 231 + border: 2px solid rgba(63, 123, 247, 0.22); 232 + border-radius: 16px; 233 padding: 9px 11px; 234 + background: var(--panel-item-bg); 235 animation: enter 220ms ease-out; 236 display: grid; 237 grid-template-columns: 34px 1fr; 238 gap: 9px; 239 + transition: background-color 280ms ease, border-color 280ms ease; 240 } 241 242 .avatar { ··· 244 height: 34px; 245 border-radius: 50%; 246 object-fit: cover; 247 + border: 2px solid rgba(255, 82, 82, 0.52); 248 + background: rgba(255, 216, 75, 0.4); 249 } 250 251 .avatar.fallback { ··· 253 place-items: center; 254 font-size: 12px; 255 font-weight: 700; 256 + color: var(--text); 257 } 258 259 .chat-user { 260 + font-size: 15px; 261 + font-weight: 750; 262 + color: var(--queued); 263 } 264 265 .chat-handle { 266 + font-size: 13px; 267 color: var(--muted); 268 } 269 ··· 277 278 .queue-panel { 279 position: absolute; 280 + left: var(--panel-offset-x); 281 + right: var(--panel-offset-x); 282 + bottom: var(--panel-offset-y); 283 display: flex; 284 flex-direction: column; 285 + gap: 8px; 286 + padding: 8px 14px 10px; 287 + border: 2px solid var(--border); 288 + border-radius: var(--frame-radius); 289 + background: var(--bg-queue); 290 + box-shadow: 0 8px 20px var(--panel-shadow); 291 + z-index: 2; 292 + overflow: visible; 293 + transition: background-color 280ms ease, border-color 280ms ease, box-shadow 280ms ease; 294 + } 295 + 296 + .spam-toast-rail { 297 + position: absolute; 298 + left: 0; 299 + right: 0; 300 + bottom: calc(100% - 2px); 301 + height: clamp(64px, 7.4vw, 110px); 302 + overflow: hidden; 303 + pointer-events: none; 304 + z-index: 4; 305 + } 306 + 307 + .spam-toast { 308 + position: absolute; 309 + left: 0; 310 + right: 0; 311 + bottom: 0; 312 + transform: translateY(110%) scale(0.98); 313 + opacity: 0; 314 + pointer-events: none; 315 + width: 100%; 316 + text-align: center; 317 + padding: 14px 20px; 318 + border-radius: var(--frame-radius); 319 + border: 3px solid transparent; 320 + font-size: clamp(22px, 3.2vw, 42px); 321 + font-weight: 900; 322 + letter-spacing: 0.04em; 323 + text-transform: uppercase; 324 + z-index: 4; 325 + transition: transform 320ms cubic-bezier(0.2, 0.9, 0.2, 1), opacity 220ms ease; 326 + } 327 + 328 + .spam-toast.show { 329 + transform: translateY(0) scale(1); 330 + opacity: 1; 331 + } 332 + 333 + .spam-toast.enabled { 334 + background: rgba(51, 185, 83, 0.92); 335 + border-color: rgba(215, 255, 223, 0.92); 336 + color: #ffffff; 337 + box-shadow: 0 14px 28px rgba(27, 116, 47, 0.42); 338 + } 339 + 340 + .spam-toast.disabled { 341 + background: rgba(211, 68, 68, 0.93); 342 + border-color: rgba(255, 219, 219, 0.92); 343 + color: #ffffff; 344 + box-shadow: 0 14px 28px rgba(120, 28, 28, 0.42); 345 + } 346 + 347 + .queue-footer { 348 + display: flex; 349 + align-items: center; 350 + justify-content: space-between; 351 gap: 10px; 352 } 353 354 .queue-list { ··· 356 padding: 0; 357 list-style: none; 358 display: flex; 359 + gap: 6px; 360 + align-items: center; 361 overflow-x: auto; 362 scrollbar-width: none; 363 } ··· 367 } 368 369 .queue-item { 370 + padding: 4px 8px 4px 9px; 371 + border-radius: 999px; 372 + border: 2px solid rgba(63, 123, 247, 0.28); 373 + background: var(--queue-pill-bg); 374 + display: flex; 375 align-items: center; 376 gap: 7px; 377 + white-space: nowrap; 378 + transition: transform 140ms ease, border-color 140ms ease, box-shadow 140ms ease, opacity 140ms ease; 379 } 380 381 + .queue-item .queue-user { 382 + color: var(--queue-user-text); 383 + font-size: 13px; 384 + line-height: 1; 385 font-weight: 700; 386 } 387 388 + .queue-item .queue-separator { 389 + color: rgba(82, 103, 160, 0.8); 390 + font-size: 12px; 391 line-height: 1; 392 } 393 394 + .queue-item .queue-command { 395 + min-width: 22px; 396 + height: 22px; 397 + padding: 0 8px; 398 + border-radius: 999px; 399 + border: 2px solid rgba(255, 82, 82, 0.45); 400 + background: var(--queue-command-bg); 401 + display: inline-flex; 402 + align-items: center; 403 + justify-content: center; 404 + color: var(--queue-command-text); 405 + font-size: 14px; 406 + line-height: 1; 407 + font-weight: 700; 408 + letter-spacing: 0.01em; 409 + transition: background-color 280ms ease, border-color 280ms ease, color 280ms ease; 410 } 411 412 + .queue-item .queue-command.compact-circle { 413 + min-width: 22px; 414 + width: 22px; 415 + padding: 0; 416 } 417 418 + .queue-item .queue-repeat { 419 + color: #3f7bf7; 420 + font-size: 12px; 421 + font-weight: 700; 422 } 423 424 .queue-item.queued { 425 + border-color: rgba(63, 123, 247, 0.52); 426 } 427 428 .queue-item.queued .state { ··· 431 432 .queue-item.active { 433 border-color: var(--active); 434 + box-shadow: 0 0 0 2px rgba(255, 82, 82, 0.2), 0 4px 12px rgba(255, 82, 82, 0.22); 435 + transform: translateY(-1px); 436 + background: #fff0f0; 437 } 438 439 .queue-item.done { 440 + opacity: 0.58; 441 + border-color: rgba(143, 159, 200, 0.5); 442 } 443 444 .queue-item.error { 445 border-color: rgba(255, 126, 126, 0.6); 446 color: var(--error); 447 } 448 449 .status { 450 font-size: 12px; 451 color: var(--muted); 452 + font-weight: 700; 453 } 454 455 @keyframes enter { ··· 473 font-size: 15px; 474 } 475 } 476 + 477 + @media (prefers-reduced-motion: reduce) { 478 + .ball-orb { 479 + transition: none; 480 + } 481 + } 482 </style> 483 </head> 484 <body> 485 <div class="overlay"> 486 + <div class="orb-field" aria-hidden="true"></div> 487 + 488 <section class="chat-panel"> 489 <ul id="chat-list" class="chat-list"></ul> 490 </section> 491 492 <section class="queue-panel"> 493 + <ul id="queue-list" class="queue-list"></ul> 494 + <div class="queue-footer"> 495 + <div id="status" class="status">Connecting...</div> 496 <div id="spam-ability" class="ability-badge">Spam: Unknown</div> 497 </div> 498 + <div class="spam-toast-rail"> 499 + <div id="spam-toast" class="spam-toast" aria-live="polite"></div> 500 + </div> 501 </section> 502 </div> 503 504 <script> 505 + const DAY_START_HOUR = 6; 506 + const NIGHT_START_HOUR = 18; 507 const chatList = document.getElementById("chat-list"); 508 const queueList = document.getElementById("queue-list"); 509 const statusEl = document.getElementById("status"); 510 const spamAbilityEl = document.getElementById("spam-ability"); 511 + const spamToastEl = document.getElementById("spam-toast"); 512 + const orbFieldEl = document.querySelector(".orb-field"); 513 const query = new URLSearchParams(window.location.search); 514 const chatMode = query.get("chat") || "non_commands"; 515 const chatNodes = new Map(); 516 const queueNodes = new Map(); 517 + let lastSpamEnabled = null; 518 + let spamToastTimer = null; 519 + const BALL_SPRITES = [ 520 + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/poke-ball.png", 521 + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/great-ball.png", 522 + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/ultra-ball.png", 523 + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/master-ball.png", 524 + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/premier-ball.png", 525 + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/dusk-ball.png", 526 + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/timer-ball.png", 527 + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/luxury-ball.png", 528 + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/quick-ball.png", 529 + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/heal-ball.png", 530 + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/nest-ball.png", 531 + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/repeat-ball.png", 532 + ]; 533 + 534 + function themeByHour(hour) { 535 + return hour >= DAY_START_HOUR && hour < NIGHT_START_HOUR ? "day" : "night"; 536 + } 537 + 538 + function applyThemeFromLocalTime() { 539 + const now = new Date(); 540 + document.documentElement.setAttribute("data-theme", themeByHour(now.getHours())); 541 + } 542 + 543 + function showSpamToast(enabled) { 544 + if (!spamToastEl) { 545 + return; 546 + } 547 + 548 + spamToastEl.textContent = enabled ? "SPAM COMMANDS" : "SPAM DISABLED"; 549 + spamToastEl.classList.toggle("enabled", enabled); 550 + spamToastEl.classList.toggle("disabled", !enabled); 551 + spamToastEl.classList.remove("show"); 552 + void spamToastEl.offsetWidth; 553 + spamToastEl.classList.add("show"); 554 + 555 + if (spamToastTimer) { 556 + clearTimeout(spamToastTimer); 557 + } 558 + spamToastTimer = setTimeout(() => { 559 + spamToastEl.classList.remove("show"); 560 + }, 2400); 561 + } 562 + 563 + function populateOrbField() { 564 + if (!orbFieldEl) { 565 + return; 566 + } 567 + const orbCount = 120; 568 + orbFieldEl.replaceChildren(); 569 + const width = window.innerWidth; 570 + const height = window.innerHeight; 571 + 572 + for (let i = 0; i < orbCount; i += 1) { 573 + const orb = document.createElement("div"); 574 + orb.className = "ball-orb"; 575 + const scale = 0.56 + Math.random() * 0.7; 576 + orb.style.setProperty("--orb-scale", scale.toFixed(3)); 577 + orb.style.opacity = `${0.2 + Math.random() * 0.35}`; 578 + orb.style.left = `${Math.floor(Math.random() * Math.max(1, width - 84))}px`; 579 + orb.style.top = `${Math.floor(Math.random() * Math.max(1, height - 84))}px`; 580 + 581 + const img = document.createElement("img"); 582 + img.className = "ball-sprite"; 583 + img.src = BALL_SPRITES[i % BALL_SPRITES.length]; 584 + img.alt = ""; 585 + orb.append(img); 586 + orbFieldEl.append(orb); 587 + } 588 + } 589 + 590 + function startOrbBounce() { 591 + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { 592 + return; 593 + } 594 + 595 + const overlay = document.querySelector(".overlay"); 596 + const orbs = Array.from(document.querySelectorAll(".ball-orb")); 597 + if (!overlay || orbs.length === 0) { 598 + return; 599 + } 600 + 601 + const states = orbs.map((orb, index) => { 602 + const scale = parseFloat(getComputedStyle(orb).getPropertyValue("--orb-scale")) || 1; 603 + const width = orb.offsetWidth * scale; 604 + const height = orb.offsetHeight * scale; 605 + const angle = ((index * 67) % 360) * (Math.PI / 180); 606 + const speed = 14 + (index % 5) * 3; 607 + 608 + return { 609 + orb, 610 + x: orb.offsetLeft, 611 + y: orb.offsetTop, 612 + vx: Math.cos(angle) * (speed / 1000), 613 + vy: Math.sin(angle) * (speed / 1000), 614 + width, 615 + height, 616 + }; 617 + }); 618 + 619 + function refreshBounds() { 620 + const maxWidth = window.innerWidth; 621 + const maxHeight = window.innerHeight; 622 + 623 + for (const state of states) { 624 + const scale = parseFloat(getComputedStyle(state.orb).getPropertyValue("--orb-scale")) || 1; 625 + state.width = state.orb.offsetWidth * scale; 626 + state.height = state.orb.offsetHeight * scale; 627 + state.x = Math.min(Math.max(state.x, 0), Math.max(0, maxWidth - state.width)); 628 + state.y = Math.min(Math.max(state.y, 0), Math.max(0, maxHeight - state.height)); 629 + state.orb.style.left = `${state.x}px`; 630 + state.orb.style.top = `${state.y}px`; 631 + } 632 + } 633 + 634 + let lastTs = performance.now(); 635 + refreshBounds(); 636 + 637 + function frame(ts) { 638 + const dt = Math.min(50, ts - lastTs); 639 + lastTs = ts; 640 + const maxWidth = window.innerWidth; 641 + const maxHeight = window.innerHeight; 642 + 643 + for (const state of states) { 644 + if (getComputedStyle(state.orb).display === "none") { 645 + continue; 646 + } 647 + 648 + state.x += state.vx * dt; 649 + state.y += state.vy * dt; 650 + 651 + const maxX = Math.max(0, maxWidth - state.width); 652 + const maxY = Math.max(0, maxHeight - state.height); 653 + 654 + if (state.x <= 0) { 655 + state.x = 0; 656 + state.vx = Math.abs(state.vx); 657 + } else if (state.x >= maxX) { 658 + state.x = maxX; 659 + state.vx = -Math.abs(state.vx); 660 + } 661 + 662 + if (state.y <= 0) { 663 + state.y = 0; 664 + state.vy = Math.abs(state.vy); 665 + } else if (state.y >= maxY) { 666 + state.y = maxY; 667 + state.vy = -Math.abs(state.vy); 668 + } 669 + 670 + state.orb.style.left = `${state.x}px`; 671 + state.orb.style.top = `${state.y}px`; 672 + } 673 + 674 + requestAnimationFrame(frame); 675 + } 676 + 677 + window.addEventListener("resize", refreshBounds); 678 + requestAnimationFrame(frame); 679 + } 680 + 681 + applyThemeFromLocalTime(); 682 + populateOrbField(); 683 + setInterval(applyThemeFromLocalTime, 60 * 1000); 684 + startOrbBounce(); 685 686 function initial(text) { 687 const trimmed = (text || "").trim(); ··· 773 function patchChatNode(li, msg) { 774 setAvatar(li._avatarWrap, msg.avatarUrl, msg.user, "avatar", () => scheduleStickToEnd(chatList)); 775 li._user.textContent = msg.user || ""; 776 + li._handle.textContent = msg.handle ? ` @${msg.handle.replace(/^@/, "")}` : ` ${shortDid(msg.did)}`; 777 li._text.textContent = msg.text || ""; 778 } 779 ··· 781 const li = document.createElement("li"); 782 li.className = "queue-item"; 783 784 + const user = document.createElement("span"); 785 + user.className = "queue-user"; 786 + const separator = document.createElement("span"); 787 + separator.className = "queue-separator"; 788 + separator.textContent = ":"; 789 const cmd = document.createElement("span"); 790 + cmd.className = "queue-command"; 791 + const repeat = document.createElement("span"); 792 + repeat.className = "queue-repeat"; 793 794 + li.append(user, separator, cmd, repeat); 795 li._user = user; 796 + li._cmd = cmd; 797 + li._repeat = repeat; 798 return li; 799 } 800 801 + function formatQueueCommand(command) { 802 + const normalized = (command || "").trim().toLowerCase(); 803 + if (!normalized) { 804 + return ""; 805 + } 806 + 807 + const isHold = normalized.endsWith("-"); 808 + const baseCommand = isHold ? normalized.slice(0, -1) : normalized; 809 + const tokens = baseCommand 810 + .split("+") 811 + .map((part) => part.trim()) 812 + .filter(Boolean) 813 + .map((part) => { 814 + switch (part) { 815 + case "up": 816 + return "↑"; 817 + case "down": 818 + return "↓"; 819 + case "left": 820 + return "←"; 821 + case "right": 822 + return "→"; 823 + case "a": 824 + return "Ⓐ"; 825 + case "b": 826 + return "Ⓑ"; 827 + case "l": 828 + return "Ⓛ"; 829 + case "r": 830 + return "Ⓡ"; 831 + case "start": 832 + return "START"; 833 + case "select": 834 + return "SELECT"; 835 + default: 836 + return part.toUpperCase(); 837 + } 838 + }); 839 + 840 + return `${tokens.join("+")}${isHold ? "-" : ""}`; 841 + } 842 + 843 + function queueLabel(item) { 844 + return item.handle ? `@${item.handle.replace(/^@/, "")}` : shortDid(item.did); 845 + } 846 + 847 + function collapseQueue(queue, activeCommandId) { 848 + const collapsed = []; 849 + 850 + for (const item of queue) { 851 + const activeState = activeCommandId === item.id ? "active" : item.status; 852 + const last = collapsed[collapsed.length - 1]; 853 + const canMerge = 854 + last && 855 + last.status !== "active" && 856 + activeState !== "active" && 857 + last.userLabel === queueLabel(item) && 858 + last.commandLabel === formatQueueCommand(item.command); 859 + 860 + if (canMerge) { 861 + last.count += 1; 862 + continue; 863 + } 864 + 865 + collapsed.push({ 866 + id: item.id, 867 + status: activeState, 868 + userLabel: queueLabel(item), 869 + commandLabel: formatQueueCommand(item.command), 870 + count: 1, 871 + }); 872 + } 873 + 874 + return collapsed; 875 + } 876 + 877 + function patchQueueNode(li, item) { 878 + li.className = `queue-item ${item.status || "queued"}`; 879 + li._user.textContent = item.userLabel; 880 + li._cmd.textContent = item.commandLabel; 881 + li._cmd.classList.toggle( 882 + "compact-circle", 883 + /^[←→↑↓ⒶⒷⓁⓇ]$/.test(item.commandLabel || ""), 884 ); 885 + li._repeat.textContent = item.count > 1 ? `x${item.count}` : ""; 886 } 887 888 function reconcileList(listEl, nodesById, items, patchNode, createNode, ...patchArgs) { ··· 919 reconcileList(chatList, chatNodes, filterChat(state.chat), patchChatNode, createChatNode); 920 scheduleStickToEnd(chatList); 921 922 + const collapsedQueue = collapseQueue(state.queue, state.activeCommandId); 923 + reconcileList(queueList, queueNodes, collapsedQueue, patchQueueNode, createQueueNode); 924 scheduleStickToEnd(queueList, true); 925 926 if (spamAbilityEl && state.spamAbility) { ··· 928 spamAbilityEl.classList.toggle("enabled", enabled); 929 spamAbilityEl.classList.toggle("disabled", !enabled); 930 spamAbilityEl.textContent = `Spam: ${enabled ? "ENABLED" : "DISABLED"} (${state.spamAbility.uniqueChatters}/${state.spamAbility.threshold} chatters, ${state.spamAbility.windowMinutes}m)`; 931 + 932 + if (lastSpamEnabled === null) { 933 + lastSpamEnabled = enabled; 934 + } else if (lastSpamEnabled !== enabled) { 935 + showSpamToast(enabled); 936 + lastSpamEnabled = enabled; 937 + } 938 } 939 + 940 } 941 942 const events = new EventSource("/events");