Monorepo for Tangled
0

Configure Feed

Select the types of activity you want to include in your feed.

Hello from local-first conf, adding malte-j profile script

Malte (Jul 14, 2026, 1:21 PM +0200) b70a3908 5022bdbc

+557
+557
appview/pages/profile-fx/malte-j.js
··· 1 + /** 2 + * punchcard.js — Multiplayer reaction-time game on the Tangled punchcard grid. 3 + * 4 + * One self-contained vanilla JS ES module. No imports, no libraries. 5 + * 6 + * Game: 7 + * - Random dots light up for 1 second 8 + * - First player to click the lit dot scores a point 9 + * - A live scoreboard shows all players and their scores 10 + * - Remote cursors are visible so you can see who you're racing 11 + * 12 + * Grid API: 13 + * - document.querySelector("[data-punchcard]") → grid container 14 + * - Each direct child is a cell wrapper; wrapper.firstElementChild is the dot <div> 15 + * - Columns: 14 on wide screens (≥768px), 28 on narrow 16 + */ 17 + 18 + /* ── Color palette for players ─────────────────────────────────── */ 19 + const PLAYER_COLORS = [ 20 + "#f472b6", // pink 21 + "#fb923c", // orange 22 + "#a78bfa", // violet 23 + "#34d399", // emerald 24 + "#38bdf8", // sky 25 + "#fbbf24", // amber 26 + "#f87171", // red 27 + "#2dd4bf", // teal 28 + "#818cf8", // indigo 29 + "#e879f9", // fuchsia 30 + ]; 31 + 32 + function colorForUser(userId) { 33 + let hash = 0; 34 + for (let i = 0; i < userId.length; i++) { 35 + hash = (hash * 31 + userId.charCodeAt(i)) | 0; 36 + } 37 + return PLAYER_COLORS[Math.abs(hash) % PLAYER_COLORS.length]; 38 + } 39 + 40 + /* ── WebSocket URL resolution ──────────────────────────────────── */ 41 + function resolveWsUrl(grid) { 42 + const explicit = grid.getAttribute("data-punchcard-ws"); 43 + if (explicit) return explicit; 44 + 45 + const room = encodeURIComponent(location.pathname); 46 + const base = 47 + grid.getAttribute("data-punchcard-ws-base") || 48 + "wss://punchcard-mp.malts.workers.dev"; 49 + 50 + return `${base}/ws?room=${room}`; 51 + } 52 + 53 + /* ── Main effect ───────────────────────────────────────────────── */ 54 + function g(N) { 55 + if (matchMedia("(prefers-reduced-motion: reduce)").matches) { 56 + return () => {}; 57 + } 58 + 59 + const grid = N; 60 + if (!grid) return () => {}; 61 + 62 + const wrappers = [...grid.children]; 63 + const dots = wrappers.map((w) => w.firstElementChild); 64 + const totalDots = dots.length; 65 + 66 + if (totalDots === 0) return () => {}; 67 + 68 + const cols = matchMedia("(min-width: 768px)").matches ? 14 : 28; 69 + 70 + // Save original dot styles 71 + const originalStyles = dots.map((d) => ({ 72 + bg: d.style.backgroundColor, 73 + transform: d.style.transform, 74 + opacity: d.style.opacity, 75 + transition: d.style.transition, 76 + boxShadow: d.style.boxShadow, 77 + })); 78 + 79 + // Local state 80 + const cursors = new Map(); // userId → { el, timeout } 81 + let scores = {}; // userId → score 82 + let myUserId = null; 83 + let activeDotIndex = null; 84 + let ws = null; 85 + let disposed = false; 86 + let reconnectTimer = null; 87 + let activeDotTimer = null; 88 + 89 + // Inject keyframe animations 90 + const styleSheet = document.createElement("style"); 91 + styleSheet.textContent = ` 92 + @keyframes punchcard-pulse { 93 + 0%, 100% { transform: scale(1); } 94 + 50% { transform: scale(1.4); } 95 + } 96 + @keyframes punchcard-pop { 97 + 0% { transform: scale(1.6); opacity: 1; } 98 + 100% { transform: scale(1); opacity: 0.7; } 99 + } 100 + `; 101 + document.head.appendChild(styleSheet); 102 + 103 + // Container for cursor overlays and scoreboard 104 + const overlayContainer = document.createElement("div"); 105 + overlayContainer.style.cssText = 106 + "position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:50;overflow:visible;"; 107 + 108 + const gridParent = grid.parentElement; 109 + if (gridParent) { 110 + const pos = getComputedStyle(gridParent).position; 111 + if (pos === "static") gridParent.style.position = "relative"; 112 + gridParent.appendChild(overlayContainer); 113 + } 114 + 115 + /* ── Scoreboard ─────────────────────────────────────────────── */ 116 + 117 + const scoreboard = document.createElement("div"); 118 + scoreboard.style.cssText = ` 119 + position: absolute; 120 + top: -8px; 121 + right: 0; 122 + transform: translateY(-100%); 123 + pointer-events: auto; 124 + display: flex; 125 + gap: 8px; 126 + flex-wrap: wrap; 127 + font-family: system-ui, -apple-system, sans-serif; 128 + font-size: 11px; 129 + z-index: 60; 130 + `; 131 + overlayContainer.appendChild(scoreboard); 132 + 133 + function renderScoreboard() { 134 + scoreboard.innerHTML = ""; 135 + 136 + // Sort by score descending 137 + const entries = Object.entries(scores).sort((a, b) => b[1] - a[1]); 138 + 139 + for (const [userId, score] of entries) { 140 + const color = colorForUser(userId); 141 + const isMe = userId === myUserId; 142 + 143 + const pill = document.createElement("div"); 144 + pill.style.cssText = ` 145 + display: inline-flex; 146 + align-items: center; 147 + gap: 4px; 148 + padding: 2px 8px; 149 + border-radius: 9999px; 150 + background: ${color}22; 151 + border: 1.5px solid ${color}${isMe ? "" : "66"}; 152 + color: ${color}; 153 + font-weight: ${isMe ? "700" : "500"}; 154 + font-variant-numeric: tabular-nums; 155 + line-height: 1.6; 156 + white-space: nowrap; 157 + ${isMe ? `box-shadow: 0 0 6px ${color}44;` : ""} 158 + `; 159 + pill.innerHTML = ` 160 + <span style=" 161 + width: 6px; height: 6px; border-radius: 50%; 162 + background: ${color}; 163 + display: inline-block; 164 + "></span> 165 + ${isMe ? "you" : userId} 166 + <span style="font-weight: 800;">${score}</span> 167 + `; 168 + scoreboard.appendChild(pill); 169 + } 170 + updateCursorScores(); 171 + } 172 + 173 + /* ── Active dot styling ─────────────────────────────────────── */ 174 + 175 + const ACTIVE_COLOR = "#a855f7"; // purple 176 + 177 + function activateDot(index) { 178 + if (index < 0 || index >= totalDots) return; 179 + 180 + // Deactivate previous if any 181 + if (activeDotIndex !== null) deactivateDot(activeDotIndex); 182 + 183 + activeDotIndex = index; 184 + const dot = dots[index]; 185 + if (!dot) return; 186 + 187 + dot.style.transition = "background-color 0.15s ease-out"; 188 + dot.style.backgroundColor = ACTIVE_COLOR; 189 + } 190 + 191 + function deactivateDot(index) { 192 + if (index < 0 || index >= totalDots) return; 193 + if (activeDotIndex === index) activeDotIndex = null; 194 + 195 + const dot = dots[index]; 196 + if (!dot) return; 197 + const orig = originalStyles[index]; 198 + 199 + dot.style.transition = "all 0.3s ease"; 200 + dot.style.animation = ""; 201 + dot.style.backgroundColor = orig.bg || ""; 202 + dot.style.transform = orig.transform || ""; 203 + dot.style.boxShadow = orig.boxShadow || ""; 204 + } 205 + 206 + function flashDotScored(index, userId) { 207 + if (index < 0 || index >= totalDots) return; 208 + 209 + const dot = dots[index]; 210 + if (!dot) return; 211 + const color = colorForUser(userId); 212 + 213 + // Flash in the scorer's color 214 + dot.style.transition = "none"; 215 + dot.style.animation = ""; 216 + dot.style.backgroundColor = color; 217 + dot.style.transform = "scale(1.8)"; 218 + dot.style.boxShadow = `0 0 16px 6px ${color}88`; 219 + 220 + // Pop animation then reset 221 + requestAnimationFrame(() => { 222 + dot.style.transition = "all 0.4s cubic-bezier(0.34,1.56,0.64,1)"; 223 + dot.style.transform = originalStyles[index].transform || ""; 224 + dot.style.boxShadow = originalStyles[index].boxShadow || ""; 225 + setTimeout(() => { 226 + if (!disposed) { 227 + dot.style.backgroundColor = originalStyles[index].bg || ""; 228 + } 229 + }, 400); 230 + }); 231 + 232 + activeDotIndex = null; 233 + } 234 + 235 + /* ── Cursor management ──────────────────────────────────────── */ 236 + 237 + function updateCursorScores() { 238 + for (const [userId, entry] of cursors) { 239 + const scoreEl = entry.el.querySelector(".cursor-score"); 240 + if (scoreEl) scoreEl.textContent = scores[userId] || 0; 241 + } 242 + } 243 + 244 + function createCursorEl(userId, isMe = false) { 245 + const color = colorForUser(userId); 246 + const score = scores[userId] || 0; 247 + 248 + const el = document.createElement("div"); 249 + el.style.cssText = ` 250 + position: absolute; 251 + pointer-events: none; 252 + z-index: 100; 253 + transition: left 0.08s linear, top 0.08s linear, opacity 0.3s ease; 254 + opacity: 1; 255 + `; 256 + 257 + const arrow = isMe 258 + ? "" 259 + : ` 260 + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" style="filter: drop-shadow(0 1px 2px rgba(0,0,0,0.3));"> 261 + <path d="M5.65 3.15l13.2 7.6a.5.5 0 0 1 0 .87l-5.15 2.97-2.97 5.15a.5.5 0 0 1-.87 0L3.15 5.65a.5.5 0 0 1 .5-.5z" 262 + fill="${color}" stroke="white" stroke-width="1.2"/> 263 + </svg> 264 + `; 265 + 266 + const offsetLeft = isMe ? "10px" : "16px"; 267 + const offsetTop = isMe ? "20px" : "14px"; 268 + const label = isMe 269 + ? `<span class="cursor-score">${score}</span>` 270 + : `${userId} <span style="opacity:0.8;margin:0 2px">•</span> <span class="cursor-score">${score}</span>`; 271 + 272 + el.innerHTML = ` 273 + ${arrow} 274 + <span style=" 275 + position: absolute; 276 + left: ${offsetLeft}; 277 + top: ${offsetTop}; 278 + background: ${color}; 279 + color: white; 280 + font-size: 10px; 281 + font-family: system-ui, sans-serif; 282 + padding: 1px 5px; 283 + border-radius: 3px; 284 + white-space: nowrap; 285 + font-weight: 600; 286 + line-height: 1.4; 287 + box-shadow: 0 1px 3px rgba(0,0,0,0.2); 288 + ">${label}</span> 289 + `; 290 + 291 + overlayContainer.appendChild(el); 292 + return el; 293 + } 294 + 295 + function updateCursor(userId, x, y, isMe = false) { 296 + let entry = cursors.get(userId); 297 + if (!entry) { 298 + entry = { el: createCursorEl(userId, isMe), timeout: null }; 299 + cursors.set(userId, entry); 300 + } 301 + 302 + const gridRect = grid.getBoundingClientRect(); 303 + const containerRect = overlayContainer.getBoundingClientRect(); 304 + 305 + const px = x * gridRect.width + (gridRect.left - containerRect.left); 306 + const py = y * gridRect.height + (gridRect.top - containerRect.top); 307 + 308 + entry.el.style.left = `${px}px`; 309 + entry.el.style.top = `${py}px`; 310 + 311 + clearTimeout(entry.timeout); 312 + entry.timeout = setTimeout(() => { 313 + entry.el.style.opacity = "0"; 314 + }, 5000); 315 + entry.el.style.opacity = "1"; 316 + } 317 + 318 + function removeCursor(userId) { 319 + const entry = cursors.get(userId); 320 + if (!entry) return; 321 + clearTimeout(entry.timeout); 322 + entry.el.style.opacity = "0"; 323 + setTimeout(() => { 324 + entry.el.remove(); 325 + cursors.delete(userId); 326 + }, 300); 327 + } 328 + 329 + /* ── WebSocket connection ───────────────────────────────────── */ 330 + 331 + function connect() { 332 + if (disposed) return; 333 + if ( 334 + ws && 335 + (ws.readyState === WebSocket.OPEN || 336 + ws.readyState === WebSocket.CONNECTING) 337 + ) 338 + return; 339 + 340 + const url = resolveWsUrl(grid); 341 + console.log("[punchcard] connecting to", url); 342 + ws = new WebSocket(url); 343 + 344 + ws.addEventListener("open", () => { 345 + console.log( 346 + "[punchcard] connected, sending init with", 347 + totalDots, 348 + "dots", 349 + ); 350 + ws.send(JSON.stringify({ type: "init", totalDots })); 351 + }); 352 + 353 + ws.addEventListener("message", (event) => { 354 + let data; 355 + try { 356 + data = JSON.parse(event.data); 357 + } catch { 358 + return; 359 + } 360 + 361 + switch (data.type) { 362 + case "welcome": 363 + console.log( 364 + "[punchcard] welcome as", 365 + data.userId, 366 + "peers:", 367 + data.peerCount, 368 + ); 369 + myUserId = data.userId; 370 + scores = data.scores || {}; 371 + renderScoreboard(); 372 + // If a dot is already active, show it 373 + if (data.activeDot !== null && data.activeDot !== undefined) { 374 + activateDot(data.activeDot); 375 + } 376 + break; 377 + 378 + case "activate": 379 + activateDot(data.index); 380 + break; 381 + 382 + case "deactivate": 383 + deactivateDot(data.index); 384 + break; 385 + 386 + case "scored": 387 + flashDotScored(data.index, data.userId); 388 + scores = data.scores || scores; 389 + renderScoreboard(); 390 + break; 391 + 392 + case "scores": 393 + scores = data.scores || {}; 394 + renderScoreboard(); 395 + break; 396 + 397 + case "cursor": 398 + if (data.userId !== myUserId) { 399 + updateCursor(data.userId, data.x, data.y); 400 + } 401 + break; 402 + 403 + case "join": 404 + break; 405 + 406 + case "leave": 407 + removeCursor(data.userId); 408 + // Score removal handled by "scores" message 409 + break; 410 + } 411 + }); 412 + 413 + ws.addEventListener("close", () => { 414 + console.log("[punchcard] disconnected, reconnecting in 2s"); 415 + ws = null; 416 + if (!disposed) { 417 + reconnectTimer = setTimeout(connect, 2000); 418 + } 419 + }); 420 + 421 + ws.addEventListener("error", (e) => { 422 + console.warn("[punchcard] ws error", e); 423 + }); 424 + } 425 + 426 + /* ── Mouse tracking ─────────────────────────────────────────── */ 427 + 428 + const ac = new AbortController(); 429 + let lastCursorSend = 0; 430 + 431 + grid.addEventListener( 432 + "pointermove", 433 + (e) => { 434 + const rect = grid.getBoundingClientRect(); 435 + const x = (e.clientX - rect.left) / rect.width; 436 + const y = (e.clientY - rect.top) / rect.height; 437 + 438 + if (myUserId) { 439 + updateCursor(myUserId, x, y, true); 440 + } 441 + 442 + const now = performance.now(); 443 + if (now - lastCursorSend < 33) return; 444 + lastCursorSend = now; 445 + 446 + if (!ws || ws.readyState !== WebSocket.OPEN) return; 447 + 448 + ws.send(JSON.stringify({ type: "cursor", x, y })); 449 + }, 450 + { signal: ac.signal }, 451 + ); 452 + 453 + /* ── Click to score ─────────────────────────────────────────── */ 454 + 455 + grid.addEventListener( 456 + "click", 457 + (e) => { 458 + if (!ws || ws.readyState !== WebSocket.OPEN) return; 459 + if (activeDotIndex === null) return; 460 + 461 + // Find which dot was clicked 462 + const target = e.target; 463 + let index = dots.indexOf(target); 464 + 465 + if (index === -1) { 466 + // Maybe clicked the wrapper 467 + const dotChild = target.firstElementChild; 468 + index = dots.indexOf(dotChild); 469 + } 470 + 471 + if (index === -1) return; 472 + 473 + // Only send if it's the active dot 474 + if (index === activeDotIndex) { 475 + ws.send(JSON.stringify({ type: "click", index })); 476 + } 477 + }, 478 + { signal: ac.signal }, 479 + ); 480 + 481 + // Make dots look clickable 482 + dots.forEach((dot) => { 483 + if (dot) dot.style.cursor = "pointer"; 484 + }); 485 + 486 + /* ── Visibility-based pause/resume ─────────────────────────── */ 487 + 488 + let vs = true; 489 + 490 + const io = new IntersectionObserver((entries) => { 491 + const visible = entries.some((e) => e.isIntersecting); 492 + if (visible === vs) return; 493 + vs = visible; 494 + if (visible) { 495 + connect(); 496 + } else { 497 + if (ws) { 498 + ws.close(1000, "Not visible"); 499 + ws = null; 500 + } 501 + clearTimeout(reconnectTimer); 502 + for (const [userId] of cursors) { 503 + removeCursor(userId); 504 + } 505 + } 506 + }); 507 + io.observe(grid); 508 + 509 + connect(); 510 + 511 + /* ── Cleanup ────────────────────────────────────────────────── */ 512 + 513 + return () => { 514 + disposed = true; 515 + ac.abort(); 516 + io.disconnect(); 517 + clearTimeout(reconnectTimer); 518 + clearTimeout(activeDotTimer); 519 + 520 + if (ws) { 521 + ws.close(1000, "Cleanup"); 522 + ws = null; 523 + } 524 + 525 + for (const [, entry] of cursors) { 526 + clearTimeout(entry.timeout); 527 + entry.el.remove(); 528 + } 529 + cursors.clear(); 530 + overlayContainer.remove(); 531 + styleSheet.remove(); 532 + 533 + // Restore all dot styles 534 + dots.forEach((dot, i) => { 535 + if (!dot) return; 536 + const orig = originalStyles[i]; 537 + dot.style.backgroundColor = orig.bg || ""; 538 + dot.style.transform = orig.transform || ""; 539 + dot.style.boxShadow = orig.boxShadow || ""; 540 + dot.style.animation = ""; 541 + dot.style.transition = orig.transition || ""; 542 + }); 543 + }; 544 + } 545 + 546 + /* ── Boot: self-invoke and re-run on htmx:load (SPA nav) ──────── */ 547 + let Y = null; 548 + let Z = null; 549 + const J = () => { 550 + const t = document.querySelector("[data-punchcard]"); 551 + if (t === Z) return; 552 + if (Y) Y(); 553 + Z = t; 554 + Y = t ? g(t) : null; 555 + }; 556 + J(); 557 + document.addEventListener("htmx:load", J);