Monorepo for Tangled
0

Configure Feed

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

appview/pages/profile-fx: more profile-fx additions

appview/pages/profile-fx/danschmidt.js: danschmidt <d_schmidt@coloradocollege.edu>
appview/pages/profile-fx/mihaizaurus.at.js: Mihai <73397939+mihaizaurus@users.noreply.github.com>
appview/state/profile.go: Lewis <lewis@tangled.org>

Lewis: May this revision serve well! <lewis@tangled.org>

Lewis (Jul 15, 2026, 8:13 AM +0200) 3c4b56a6 81a65ebb

+1227
+551
appview/pages/profile-fx/danschmidt.js
··· 1 + // A lane-crossing dodge game on the Tangled punchcard. 2 + // Guide the chicken from one edge of the grid to the other without getting 3 + // clipped by the moving hazards in the "traffic" lanes. Arrow keys / WASD 4 + // once the grid is focused, or tap in the direction you want to move. 5 + // Reduced motion gets a turn-based variant: hazards only move when you do. 6 + // 7 + // Levels alternate direction: odd levels climb to the top, even levels 8 + // descend to the bottom, and so on forever, getting faster each time. 9 + // Losing all your lives turns the whole grid into a plate of drumsticks. 10 + // 11 + // Tune the constants below to change difficulty and pacing. 12 + 13 + const COLS_WIDE = 14; 14 + const COLS_NARROW = 28; 15 + const WIDE_QUERY = "(min-width: 768px)"; 16 + const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"; 17 + 18 + const LIVES_START = 3; 19 + const BASE_SPEED = 0.7; // cells/sec for the easiest danger lane 20 + const SPEED_RAMP = 0.06; // extra cells/sec per lane closer to the goal 21 + const SAFE_ROW_INTERVAL = 3; // every Nth interior row is a resting lane 22 + const CAR_DENSITY_DIVISOR = 11; // larger = fewer cars per lane at level 1 23 + const CARS_PER_LEVEL = 0.5; // extra cars per lane added every ~2 levels 24 + const MAX_CARS_PER_LANE = 6; // cap so late levels don't get absurdly crowded 25 + const WIN_PAUSE_MS = 1600; 26 + const HIT_FLASH_MS = 180; 27 + 28 + const PLAYER_COLOR = "hsl(45 100% 55%)"; 29 + const PLAYER_GLYPH = "🐔"; 30 + const PLAYER_GLYPH_SIZE = "16px"; // tune relative to your actual dot size 31 + const HIT_COLOR = "hsl(355 85% 55%)"; 32 + const HAZARD_GLYPH = "🚗"; 33 + const HAZARD_GLYPH_SIZE = "16px"; // tune relative to your actual dot size 34 + const HAZARD_BG = "hsla(330, 85%, 62%, 0.55)"; 35 + const HAZARD_BG_REVERSE = "hsla(280, 80%, 62%, 0.55)"; 36 + const GAME_OVER_GLYPH = "🍗"; 37 + const GAME_OVER_GLYPH_SIZE = "16px"; 38 + const GRASS_HUE = 100; 39 + const GRASS_HUE_JITTER = 18; 40 + const GRASS_LIGHTNESS_MIN = 36; 41 + const GRASS_LIGHTNESS_MAX = 58; 42 + const GRASS_SCALE_MIN = 0.65; 43 + const GRASS_SCALE_MAX = 1.3; 44 + 45 + function getCols() { 46 + return matchMedia(WIDE_QUERY).matches ? COLS_WIDE : COLS_NARROW; 47 + } 48 + 49 + function setupGame(grid) { 50 + const cells = Array.from(grid.children, (c) => c.firstElementChild).filter(Boolean); 51 + const total = cells.length; 52 + if (total === 0) return () => {}; 53 + 54 + const reducedMotion = matchMedia(REDUCED_MOTION_QUERY).matches; 55 + const wideQuery = matchMedia(WIDE_QUERY); 56 + 57 + let cols = getCols(); 58 + let rows = Math.ceil(total / cols); 59 + let homeRow = total % cols === 0 ? rows - 1 : rows - 2; 60 + let prevKey = new Array(total).fill(""); 61 + let lanes = []; 62 + let player = { row: 0, col: 0 }; 63 + let level = 1; 64 + let bestLevel = 1; 65 + let lives = LIVES_START; 66 + let gameOver = false; 67 + let justWon = false; 68 + let resolving = false; 69 + let raf = 0; 70 + let lastTs = 0; 71 + let visible = true; 72 + let flashTimeoutId = 0; 73 + let winTimeoutId = 0; 74 + let playerElIdx = -1; 75 + let hazardIdxs = new Set(); 76 + let audioCtx = null; 77 + 78 + cells.forEach((el) => { 79 + el.style.transformOrigin = "center"; 80 + if (!reducedMotion) { 81 + el.style.transition = "background-color 0.12s ease, transform 0.12s ease"; 82 + } 83 + }); 84 + 85 + grid.tabIndex = 0; 86 + grid.setAttribute("aria-label", "Dodge game: use arrow keys or tap to cross"); 87 + 88 + const status = document.createElement("div"); 89 + status.style.fontSize = "11px"; 90 + status.style.lineHeight = "1.4"; 91 + status.style.marginTop = "6px"; 92 + status.style.opacity = "0.75"; 93 + status.style.fontFamily = "inherit"; 94 + status.setAttribute("aria-live", "polite"); 95 + grid.insertAdjacentElement("afterend", status); 96 + 97 + let grassColor = new Array(total); 98 + let grassScale = new Array(total); 99 + function buildGrassField() { 100 + for (let i = 0; i < total; i++) { 101 + const hue = GRASS_HUE + (Math.random() * 2 - 1) * GRASS_HUE_JITTER; 102 + const light = GRASS_LIGHTNESS_MIN + Math.random() * (GRASS_LIGHTNESS_MAX - GRASS_LIGHTNESS_MIN); 103 + grassColor[i] = `hsl(${hue.toFixed(0)} 55% ${light.toFixed(0)}%)`; 104 + grassScale[i] = GRASS_SCALE_MIN + Math.random() * (GRASS_SCALE_MAX - GRASS_SCALE_MIN); 105 + } 106 + } 107 + buildGrassField(); 108 + 109 + function colsInRow(row) { 110 + return row === rows - 1 ? total - cols * (rows - 1) : cols; 111 + } 112 + 113 + // Odd levels climb from the bottom (homeRow) to the top (0). 114 + // Even levels descend from the top (0) back to the bottom (homeRow). 115 + function goalRow() { 116 + return level % 2 === 1 ? 0 : homeRow; 117 + } 118 + function startRow() { 119 + return level % 2 === 1 ? homeRow : 0; 120 + } 121 + 122 + function laneTypeFor(row) { 123 + if (row === goalRow()) return "goal"; 124 + if (row === startRow()) return "home"; 125 + if (row % SAFE_ROW_INTERVAL === 0) return "safe"; 126 + return "danger"; 127 + } 128 + 129 + function buildLanes() { 130 + lanes = new Array(rows); 131 + for (let r = 0; r <= homeRow; r++) { 132 + const type = laneTypeFor(r); 133 + if (type !== "danger") { 134 + lanes[r] = { type }; 135 + continue; 136 + } 137 + const distFromGoal = Math.abs(r - goalRow()); 138 + const baseCars = Math.max(1, Math.floor(cols / CAR_DENSITY_DIVISOR)); 139 + const bonusCars = Math.floor((level - 1) * CARS_PER_LEVEL); 140 + lanes[r] = { 141 + type, 142 + dir: r % 2 === 0 ? 1 : -1, 143 + speed: BASE_SPEED + (homeRow - distFromGoal) * SPEED_RAMP + (level - 1) * 0.08, 144 + carCount: Math.min(MAX_CARS_PER_LANE, baseCars + bonusCars), 145 + offset: Math.random() * cols, 146 + }; 147 + } 148 + } 149 + 150 + function carColumnsFor(lane) { 151 + const spacing = cols / lane.carCount; 152 + const out = []; 153 + for (let k = 0; k < lane.carCount; k++) { 154 + const pos = ((lane.offset + k * spacing) % cols + cols) % cols; 155 + out.push(Math.round(pos) % cols); 156 + } 157 + return out; 158 + } 159 + 160 + function resetPlayer() { 161 + const row = startRow(); 162 + const maxCol = colsInRow(row) - 1; 163 + player = { row, col: Math.floor(maxCol / 2) }; 164 + } 165 + 166 + function applyStyle(i, bg, scale) { 167 + const key = bg + "|" + scale; 168 + if (prevKey[i] === key) return; 169 + prevKey[i] = key; 170 + const el = cells[i]; 171 + el.style.backgroundColor = bg; 172 + el.style.transform = scale === 1 ? "" : `scale(${scale})`; 173 + } 174 + 175 + function clearCell(el) { 176 + el.style.backgroundColor = ""; 177 + el.style.transform = ""; 178 + el.textContent = ""; 179 + el.style.fontSize = ""; 180 + el.style.display = ""; 181 + el.style.alignItems = ""; 182 + el.style.justifyContent = ""; 183 + } 184 + 185 + function paintPlayer(idx) { 186 + if (playerElIdx !== -1 && playerElIdx !== idx) { 187 + clearCell(cells[playerElIdx]); 188 + prevKey[playerElIdx] = ""; 189 + } 190 + const el = cells[idx]; 191 + el.style.backgroundColor = PLAYER_COLOR; 192 + el.style.display = "flex"; 193 + el.style.alignItems = "center"; 194 + el.style.justifyContent = "center"; 195 + el.style.fontSize = PLAYER_GLYPH_SIZE; 196 + el.textContent = PLAYER_GLYPH; 197 + prevKey[idx] = "player"; 198 + playerElIdx = idx; 199 + } 200 + 201 + function paintHazard(i, dir) { 202 + const el = cells[i]; 203 + el.style.backgroundColor = dir === 1 ? HAZARD_BG_REVERSE : HAZARD_BG; 204 + el.style.display = "flex"; 205 + el.style.alignItems = "center"; 206 + el.style.justifyContent = "center"; 207 + el.style.fontSize = HAZARD_GLYPH_SIZE; 208 + el.style.transform = dir === 1 ? "scaleX(-1)" : ""; 209 + el.textContent = HAZARD_GLYPH; 210 + prevKey[i] = "hazard|" + dir; 211 + } 212 + 213 + function clearHazard(i) { 214 + clearCell(cells[i]); 215 + prevKey[i] = ""; 216 + } 217 + 218 + function renderGameOver() { 219 + for (let i = 0; i < total; i++) { 220 + const el = cells[i]; 221 + el.style.backgroundColor = ""; 222 + el.style.transform = ""; 223 + el.style.display = "flex"; 224 + el.style.alignItems = "center"; 225 + el.style.justifyContent = "center"; 226 + el.style.fontSize = GAME_OVER_GLYPH_SIZE; 227 + el.textContent = GAME_OVER_GLYPH; 228 + prevKey[i] = "gameover"; 229 + } 230 + playerElIdx = -1; 231 + hazardIdxs = new Set(); 232 + } 233 + 234 + function render() { 235 + if (gameOver) { 236 + renderGameOver(); 237 + return; 238 + } 239 + const newHazards = new Map(); 240 + for (let r = 0; r <= homeRow; r++) { 241 + const lane = lanes[r]; 242 + const rowCols = colsInRow(r); 243 + const carCols = lane.type === "danger" ? carColumnsFor(lane) : null; 244 + for (let c = 0; c < rowCols; c++) { 245 + const i = r * cols + c; 246 + if (carCols && carCols.includes(c)) { 247 + newHazards.set(i, lane.dir); 248 + } else { 249 + applyStyle(i, grassColor[i], grassScale[i]); 250 + } 251 + } 252 + } 253 + for (const i of hazardIdxs) { 254 + if (!newHazards.has(i)) clearHazard(i); 255 + } 256 + for (const [i, dir] of newHazards) { 257 + paintHazard(i, dir); 258 + } 259 + hazardIdxs = new Set(newHazards.keys()); 260 + 261 + const pIdx = player.row * cols + player.col; 262 + paintPlayer(pIdx); 263 + } 264 + 265 + function updateStatus() { 266 + if (gameOver) { 267 + status.textContent = `Game over — reached level ${level} (best ${bestLevel}). Click the grid to try again.`; 268 + return; 269 + } 270 + if (justWon) { 271 + status.textContent = `Level ${level} complete! Best ${bestLevel}`; 272 + return; 273 + } 274 + const filled = "●".repeat(lives); 275 + const empty = "○".repeat(Math.max(0, LIVES_START - lives)); 276 + const dirLabel = level % 2 === 1 ? "climb up" : "climb down"; 277 + const hint = reducedMotion ? "tap/arrows (turn-based)" : "arrows or tap"; 278 + status.textContent = `Level ${level}: ${dirLabel} · ${filled}${empty} · Best ${bestLevel} — ${hint}`; 279 + } 280 + 281 + function ensureAudio() { 282 + const AC = window.AudioContext || window.webkitAudioContext; 283 + if (!AC) return null; 284 + if (!audioCtx) audioCtx = new AC(); 285 + if (audioCtx.state === "suspended") audioCtx.resume(); 286 + return audioCtx; 287 + } 288 + 289 + function playTone(freq, duration, type, gainLevel) { 290 + const ctx = ensureAudio(); 291 + if (!ctx) return; 292 + const osc = ctx.createOscillator(); 293 + const gain = ctx.createGain(); 294 + osc.type = type; 295 + osc.frequency.value = freq; 296 + gain.gain.value = gainLevel; 297 + gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + duration); 298 + osc.connect(gain).connect(ctx.destination); 299 + osc.start(); 300 + osc.stop(ctx.currentTime + duration); 301 + } 302 + 303 + function sfxHop() { 304 + playTone(520, 0.07, "square", 0.07); 305 + } 306 + 307 + function sfxHit() { 308 + playTone(120, 0.25, "sawtooth", 0.12); 309 + } 310 + 311 + function sfxWin() { 312 + [523, 659, 784, 1047].forEach((freq, i) => { 313 + setTimeout(() => playTone(freq, 0.15, "square", 0.09), i * 90); 314 + }); 315 + } 316 + 317 + function sfxGameOver() { 318 + playTone(200, 0.35, "sawtooth", 0.1); 319 + setTimeout(() => playTone(140, 0.4, "sawtooth", 0.1), 150); 320 + } 321 + 322 + function checkCollision() { 323 + if (gameOver || resolving) return false; 324 + const lane = lanes[player.row]; 325 + if (lane.type !== "danger") return false; 326 + if (!carColumnsFor(lane).includes(player.col)) return false; 327 + 328 + resolving = true; 329 + lives--; 330 + sfxHit(); 331 + const hitIdx = player.row * cols + player.col; 332 + clearCell(cells[hitIdx]); 333 + cells[hitIdx].style.backgroundColor = HIT_COLOR; 334 + cells[hitIdx].style.transform = "scale(1.5)"; 335 + prevKey[hitIdx] = "hit"; 336 + 337 + const finalize = () => { 338 + resolving = false; 339 + if (lives <= 0) { 340 + gameOver = true; 341 + cancelAnimationFrame(raf); 342 + raf = 0; 343 + sfxGameOver(); 344 + } else { 345 + resetPlayer(); 346 + } 347 + updateStatus(); 348 + render(); 349 + }; 350 + 351 + if (reducedMotion) { 352 + finalize(); 353 + } else { 354 + flashTimeoutId = setTimeout(finalize, HIT_FLASH_MS); 355 + } 356 + return true; 357 + } 358 + 359 + function win() { 360 + clearTimeout(winTimeoutId); 361 + resolving = true; 362 + justWon = true; 363 + sfxWin(); 364 + updateStatus(); 365 + render(); 366 + winTimeoutId = setTimeout(advanceLevel, WIN_PAUSE_MS); 367 + } 368 + 369 + function advanceLevel() { 370 + level++; 371 + bestLevel = Math.max(bestLevel, level); 372 + justWon = false; 373 + resolving = false; 374 + buildLanes(); 375 + resetPlayer(); 376 + prevKey.fill(""); 377 + updateStatus(); 378 + render(); 379 + if (!reducedMotion) { 380 + lastTs = 0; 381 + if (raf === 0 && visible) raf = requestAnimationFrame(frame); 382 + } 383 + } 384 + 385 + function advanceLanesOneStep() { 386 + for (let r = 0; r <= homeRow; r++) { 387 + const lane = lanes[r]; 388 + if (lane.type !== "danger") continue; 389 + const steps = Math.max(1, Math.round(lane.speed)); 390 + lane.offset = ((lane.offset + lane.dir * steps) % cols + cols) % cols; 391 + } 392 + } 393 + 394 + function advanceLanesContinuous(dt) { 395 + for (let r = 0; r <= homeRow; r++) { 396 + const lane = lanes[r]; 397 + if (lane.type !== "danger") continue; 398 + lane.offset = ((lane.offset + lane.dir * lane.speed * dt) % cols + cols) % cols; 399 + } 400 + } 401 + 402 + function tryMove(dr, dc) { 403 + if (gameOver) { 404 + newGame(); 405 + return; 406 + } 407 + if (resolving) return; 408 + const nr = player.row + dr; 409 + const nc = player.col + dc; 410 + if (nr < 0 || nr > homeRow) return; 411 + const maxCol = colsInRow(nr) - 1; 412 + if (nc < 0 || nc > maxCol) return; 413 + 414 + player.row = nr; 415 + player.col = nc; 416 + sfxHop(); 417 + if (nr === goalRow()) { 418 + win(); 419 + return; 420 + } 421 + let hit = checkCollision(); 422 + if (!hit && reducedMotion) { 423 + advanceLanesOneStep(); 424 + hit = checkCollision(); 425 + } 426 + render(); 427 + updateStatus(); 428 + } 429 + 430 + function frame(ts) { 431 + const dt = lastTs ? (ts - lastTs) / 1000 : 0; 432 + lastTs = ts; 433 + if (!gameOver && !resolving) { 434 + advanceLanesContinuous(dt); 435 + checkCollision(); 436 + } 437 + render(); 438 + if (visible && !gameOver) { 439 + raf = requestAnimationFrame(frame); 440 + } else { 441 + raf = 0; 442 + } 443 + } 444 + 445 + function newGame() { 446 + level = 1; 447 + lives = LIVES_START; 448 + gameOver = false; 449 + justWon = false; 450 + resolving = false; 451 + playerElIdx = -1; 452 + hazardIdxs = new Set(); 453 + cells.forEach(clearCell); 454 + prevKey.fill(""); 455 + buildLanes(); 456 + resetPlayer(); 457 + updateStatus(); 458 + render(); 459 + if (!reducedMotion) { 460 + lastTs = 0; 461 + if (raf === 0 && visible) raf = requestAnimationFrame(frame); 462 + } 463 + } 464 + 465 + function onKeyDown(e) { 466 + const moves = { 467 + ArrowUp: [-1, 0], ArrowDown: [1, 0], ArrowLeft: [0, -1], ArrowRight: [0, 1], 468 + w: [-1, 0], s: [1, 0], a: [0, -1], d: [0, 1], 469 + W: [-1, 0], S: [1, 0], A: [0, -1], D: [0, 1], 470 + }; 471 + const mv = moves[e.key]; 472 + if (!mv) return; 473 + e.preventDefault(); 474 + tryMove(mv[0], mv[1]); 475 + } 476 + 477 + function onClick(e) { 478 + grid.focus({ preventScroll: true }); 479 + if (gameOver) { 480 + newGame(); 481 + return; 482 + } 483 + const rect = grid.getBoundingClientRect(); 484 + const colF = ((e.clientX - rect.left) / rect.width) * cols; 485 + const rowF = ((e.clientY - rect.top) / rect.height) * rows; 486 + const dc = colF - (player.col + 0.5); 487 + const dr = rowF - (player.row + 0.5); 488 + if (Math.abs(dc) > Math.abs(dr)) tryMove(0, dc > 0 ? 1 : -1); 489 + else tryMove(dr > 0 ? 1 : -1, 0); 490 + } 491 + 492 + grid.addEventListener("keydown", onKeyDown); 493 + grid.addEventListener("click", onClick); 494 + 495 + function rebuild() { 496 + cancelAnimationFrame(raf); 497 + raf = 0; 498 + cols = getCols(); 499 + rows = Math.ceil(total / cols); 500 + homeRow = total % cols === 0 ? rows - 1 : rows - 2; 501 + prevKey = new Array(total).fill(""); 502 + newGame(); 503 + } 504 + wideQuery.addEventListener("change", rebuild); 505 + 506 + let io = null; 507 + if (!reducedMotion) { 508 + io = new IntersectionObserver((entries) => { 509 + const nowVisible = entries.some((e) => e.isIntersecting); 510 + if (nowVisible === visible) return; 511 + visible = nowVisible; 512 + if (visible && !gameOver && raf === 0) { 513 + lastTs = 0; 514 + raf = requestAnimationFrame(frame); 515 + } else if (!visible) { 516 + cancelAnimationFrame(raf); 517 + raf = 0; 518 + } 519 + }); 520 + io.observe(grid); 521 + } 522 + 523 + newGame(); 524 + 525 + return () => { 526 + cancelAnimationFrame(raf); 527 + clearTimeout(flashTimeoutId); 528 + clearTimeout(winTimeoutId); 529 + grid.removeEventListener("keydown", onKeyDown); 530 + grid.removeEventListener("click", onClick); 531 + wideQuery.removeEventListener("change", rebuild); 532 + if (io) io.disconnect(); 533 + grid.removeAttribute("tabindex"); 534 + grid.removeAttribute("aria-label"); 535 + cells.forEach(clearCell); 536 + if (audioCtx) audioCtx.close(); 537 + status.remove(); 538 + }; 539 + } 540 + 541 + let currentGrid = null; 542 + let cleanup = null; 543 + function init() { 544 + const grid = document.querySelector("[data-punchcard]"); 545 + if (grid === currentGrid) return; 546 + if (cleanup) cleanup(); 547 + currentGrid = grid; 548 + cleanup = grid ? setupGame(grid) : null; 549 + } 550 + init(); 551 + document.addEventListener("htmx:load", init);
+674
appview/pages/profile-fx/mihaizaurus.at.js
··· 1 + // Intersex-Inclusive Progress Pride palette, arranged as left-to-right bands. 2 + const prideColors = [ 3 + "#E22016", "#F28917", "#F5E524", "#7BB82A", "#2C5B84", "#6D2380", 4 + "#000000", "#945516", "#7BCCE5", "#F4AEC8", "#FFFFFF", "#FFD817", 5 + ]; 6 + const particleShapes = ["triangle", "diamond", "dot", "hexagon"]; 7 + const shapePaths = { 8 + triangle: "polygon(50% 0, 100% 100%, 0 100%)", 9 + diamond: "polygon(50% 0, 100% 50%, 50% 100%, 0 50%)", 10 + hexagon: "polygon(25% 6.7%, 75% 6.7%, 100% 50%, 75% 93.3%, 25% 93.3%, 0 50%)", 11 + }; 12 + 13 + const wideScreen = matchMedia("(min-width: 768px)"); 14 + const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)"); 15 + let currentGrid = null; 16 + let stop = null; 17 + 18 + function animate(grid) { 19 + const dots = Array.from(grid.children, (cell) => cell.firstElementChild).filter(Boolean); 20 + if (!dots.length) return () => {}; 21 + 22 + const originalStyles = dots.map((dot) => dot.getAttribute("style")); 23 + const originalGridStyle = grid.getAttribute("style"); 24 + const maxRadius = 5; 25 + const accelerationX = new Float32Array(dots.length); 26 + const accelerationY = new Float32Array(dots.length); 27 + const effects = new Map(); 28 + const fireworkTimers = new Set(); 29 + const devourTimers = new Set(); 30 + let audioContext = null; 31 + const collisionNotes = [587, 659, 440, 659, 740, 880, 784, 740, 587, 659, 440, 440, 440, 494, 587, 587, 32 + 587, 659, 440, 659, 740, 880, 784, 740, 587, 659, 440, 440, 440, 494, 587, 587, 33 + 0, 494, 554, 587, 587, 659, 554, 494, 440, 0, 0, 494, 494, 554, 587, 494, 34 + 440, 880, 0, 880, 659, 0, 494, 494, 554, 587, 494, 587, 659, 0, 0, 554, 35 + 494, 440, 0, 0, 494, 494, 554, 587, 494, 440, 659, 659, 659, 740, 659, 0, 36 + 587, 659, 740, 587, 659, 659, 659, 740, 659, 440, 0, 494, 554, 587, 494, 0, 37 + 659, 740, 659, 440, 494, 587, 494, 740, 740, 659, 440, 494, 587, 494, 659, 659, 38 + 587, 554, 494, 440, 494, 587, 494, 587, 659, 554, 494, 440, 440, 440, 659, 587, 39 + 440, 494, 587, 494, 740, 740, 659, 440, 494, 587, 494, 880, 554, 587, 554, 494, 40 + 440, 494, 587, 494, 587, 659, 554, 494, 440, 440, 659, 587, 0, 0, 494, 587, 41 + 494, 587, 659, 0, 0, 554, 494, 440, 0, 0, 494, 494, 554, 587, 494, 440, 42 + 0, 880, 880, 659, 740, 659, 587, 0, 440, 494, 554, 587, 494, 0, 554, 494, 43 + 440, 0, 494, 494, 554, 587, 494, 440, 0, 0, 659, 659, 740, 659, 587, 587, 44 + 659, 740, 659, 659, 659, 740, 659, 440, 440, 0, 440, 494, 554, 587, 494, 0, 45 + 659, 740, 659, 440, 494, 587, 494, 740, 740, 659, 440, 494, 587, 494, 659, 659, 46 + 587, 554, 494, 440, 494, 587, 494, 587, 659, 554, 494, 440, 440, 659, 587, 440, 47 + 494, 587, 494, 740, 740, 659, 440, 494, 587, 494, 880, 554, 587, 554, 494, 440, 48 + 494, 587, 494, 587, 659, 554, 494, 440, 440, 659, 587, 440, 494, 587, 494, 740, 49 + 740, 659, 440, 494, 587, 494, 880, 554, 587, 554, 494, 440, 494, 587, 494, 587, 50 + 659, 554, 494, 440, 440, 659, 587, 440, 494, 587, 494, 740, 740, 659, 440, 494, 51 + 587, 494, 880, 554, 587, 554, 494, 440, 494, 587, 494, 587, 659, 554, 494, 440, 52 + 440, 659, 587, 0]; 53 + let collisionNoteIndex = 0; 54 + let particles = []; 55 + let aliveCount = dots.length; 56 + let fireworksShown = false; 57 + let cols = 0; 58 + let halfWidth = 1; 59 + let halfHeight = 1; 60 + let frame = 0; 61 + let resizeFrame = 0; 62 + let lastTime = 0; 63 + let visible = true; 64 + let mouse = { x: 0, y: 0, active: false }; 65 + let singularity = null; 66 + 67 + Object.assign(grid.style, { 68 + backgroundColor: "rgba(7, 8, 24, 0.2)", 69 + backgroundImage: "radial-gradient(ellipse at center, rgba(109, 35, 128, 0.34), transparent 68%)", 70 + backgroundRepeat: "no-repeat", 71 + boxShadow: "inset 0 0 20px rgba(109, 35, 128, 0.25)", 72 + cursor: "none", 73 + }); 74 + 75 + const blackHole = document.createElement("div"); 76 + const makeEye = () => { 77 + const eye = document.createElement("div"); 78 + const pupil = document.createElement("div"); 79 + Object.assign(eye.style, { 80 + position: "absolute", 81 + top: "7px", 82 + width: "10px", 83 + height: "10px", 84 + borderRadius: "50%", 85 + background: "white", 86 + overflow: "hidden", 87 + }); 88 + Object.assign(pupil.style, { 89 + position: "absolute", 90 + left: "3px", 91 + top: "3px", 92 + width: "5px", 93 + height: "5px", 94 + borderRadius: "50%", 95 + background: "#111", 96 + }); 97 + eye.append(pupil); 98 + return { eye, pupil }; 99 + }; 100 + const leftEye = makeEye(); 101 + const rightEye = makeEye(); 102 + const smile = document.createElement("div"); 103 + leftEye.eye.style.left = "5px"; 104 + rightEye.eye.style.right = "5px"; 105 + Object.assign(smile.style, { 106 + position: "absolute", 107 + left: "8px", 108 + top: "19px", 109 + width: "16px", 110 + height: "6px", 111 + borderBottom: "2px solid white", 112 + borderRadius: "0 0 60% 60%", 113 + transform: "rotate(9deg)", 114 + }); 115 + Object.assign(blackHole.style, { 116 + position: "fixed", 117 + width: "32px", 118 + height: "32px", 119 + borderRadius: "50%", 120 + background: "radial-gradient(circle at 42% 38%, #242424 0 9%, #050505 34%, #000 70%)", 121 + boxShadow: "0 0 10px rgba(109, 35, 128, 0.55)", 122 + pointerEvents: "none", 123 + display: "none", 124 + zIndex: "10002", 125 + }); 126 + blackHole.dataset.profileFx = "black-hole"; 127 + blackHole.append(leftEye.eye, rightEye.eye, smile); 128 + document.body.append(blackHole); 129 + 130 + const paintBlackHole = (time) => { 131 + const location = singularity || mouse; 132 + if (!singularity && !mouse.active) { 133 + blackHole.style.display = "none"; 134 + return; 135 + } 136 + const wobbleX = Math.sin(time / 90) * 1.4; 137 + const wobbleY = Math.cos(time / 120) * 1.2; 138 + blackHole.style.display = "block"; 139 + blackHole.style.left = `${location.screenX}px`; 140 + blackHole.style.top = `${location.screenY}px`; 141 + blackHole.style.transform = `translate(-50%, -50%) rotate(${(Math.sin(time / 280) * 4).toFixed(1)}deg)`; 142 + leftEye.pupil.style.transform = `translate(${wobbleX.toFixed(1)}px, ${wobbleY.toFixed(1)}px)`; 143 + rightEye.pupil.style.transform = `translate(${(-wobbleX * 0.7).toFixed(1)}px, ${(wobbleY * 0.8).toFixed(1)}px)`; 144 + }; 145 + 146 + const getAudioContext = () => (audioContext?.state === "running" ? audioContext : null); 147 + 148 + const playTone = (frequency, duration, delay, volume) => { 149 + const context = getAudioContext(); 150 + if (!context) return; 151 + const start = context.currentTime + delay; 152 + const oscillator = context.createOscillator(); 153 + const gain = context.createGain(); 154 + oscillator.type = "sine"; 155 + oscillator.frequency.setValueAtTime(frequency, start); 156 + gain.gain.setValueAtTime(0.0001, start); 157 + gain.gain.exponentialRampToValueAtTime(volume, start + 0.015); 158 + gain.gain.exponentialRampToValueAtTime(0.0001, start + duration); 159 + oscillator.connect(gain).connect(context.destination); 160 + oscillator.start(start); 161 + oscillator.stop(start + duration + 0.02); 162 + }; 163 + 164 + const playPop = () => { 165 + const frequency = collisionNotes[collisionNoteIndex]; 166 + collisionNoteIndex = (collisionNoteIndex + 1) % collisionNotes.length; 167 + // Zero or a negative value is a rest; advance the sequence without a tone. 168 + if (!Number.isFinite(frequency) || frequency <= 0) return; 169 + const context = getAudioContext(); 170 + if (!context) return; 171 + const start = context.currentTime; 172 + const oscillator = context.createOscillator(); 173 + const gain = context.createGain(); 174 + oscillator.type = "sine"; 175 + oscillator.frequency.setValueAtTime(frequency, start); 176 + oscillator.frequency.exponentialRampToValueAtTime(frequency * 0.82, start + 0.12); 177 + gain.gain.setValueAtTime(0.0001, start); 178 + gain.gain.exponentialRampToValueAtTime(0.05, start + 0.01); 179 + gain.gain.exponentialRampToValueAtTime(0.0001, start + 0.14); 180 + oscillator.connect(gain).connect(context.destination); 181 + oscillator.start(start); 182 + oscillator.stop(start + 0.16); 183 + }; 184 + 185 + const playTada = () => { 186 + playTone(523.25, 0.24, 0, 0.045); 187 + playTone(659.25, 0.24, 0.13, 0.045); 188 + playTone(783.99, 0.55, 0.26, 0.06); 189 + }; 190 + 191 + const unlockAudio = () => { 192 + if (!audioContext) { 193 + const AudioContext = window.AudioContext || window.webkitAudioContext; 194 + if (!AudioContext) return; 195 + audioContext = new AudioContext(); 196 + } 197 + if (audioContext.state === "suspended") audioContext.resume().catch(() => {}); 198 + }; 199 + 200 + const moveMouse = (event) => { 201 + if (singularity) return; 202 + const gridRect = grid.getBoundingClientRect(); 203 + mouse = { 204 + x: event.clientX - gridRect.left - halfWidth, 205 + y: event.clientY - gridRect.top - halfHeight, 206 + screenX: event.clientX, 207 + screenY: event.clientY, 208 + active: true, 209 + }; 210 + paintBlackHole(performance.now()); 211 + }; 212 + 213 + const leaveMouse = () => { 214 + if (!singularity) mouse = { ...mouse, active: false }; 215 + paintBlackHole(performance.now()); 216 + }; 217 + 218 + const initialRadius = (dot) => { 219 + if (dot.classList.contains("size-[4px]")) return 1; 220 + if (dot.classList.contains("size-[7px]")) return 3.5; 221 + if (dot.classList.contains("size-[6px]")) return 3; 222 + return 2.5; 223 + }; 224 + 225 + const paintParticle = (particle) => { 226 + const dot = dots[particle.index]; 227 + const diameter = (particle.baseRadius * 2).toFixed(2); 228 + const path = shapePaths[particle.shape]; 229 + const color = dot.style.backgroundColor || prideColors[particle.colorIndex]; 230 + const glow = (1.5 + Math.min(particle.radius, maxRadius) * 0.8).toFixed(1); 231 + dot.style.width = `${diameter}px`; 232 + dot.style.height = `${diameter}px`; 233 + dot.style.minWidth = `${diameter}px`; 234 + dot.style.minHeight = `${diameter}px`; 235 + dot.style.maxWidth = "none"; 236 + dot.style.maxHeight = "none"; 237 + dot.style.aspectRatio = "1 / 1"; 238 + dot.style.borderRadius = particle.shape === "dot" ? "50%" : "0"; 239 + dot.style.clipPath = path || "none"; 240 + dot.style.webkitClipPath = path || "none"; 241 + dot.style.filter = `saturate(1.45) brightness(1.18) drop-shadow(0 0 ${glow}px ${color})`; 242 + dot.style.flexShrink = "0"; 243 + dot.style.opacity = particle.radius < 1.5 ? "0.45" : "1"; 244 + dot.style.visibility = particle.alive ? "visible" : "hidden"; 245 + }; 246 + 247 + const showBlast = (particle) => { 248 + const gridRect = grid.getBoundingClientRect(); 249 + const color = dots[particle.index].style.backgroundColor; 250 + const blast = document.createElement("div"); 251 + const size = Math.max(8, particle.radius * 4); 252 + 253 + Object.assign(blast.style, { 254 + position: "fixed", 255 + left: `${gridRect.left + gridRect.width / 2 + particle.x}px`, 256 + top: `${gridRect.top + gridRect.height / 2 + particle.y}px`, 257 + width: `${size}px`, 258 + height: `${size}px`, 259 + border: `1px solid ${color}`, 260 + borderRadius: "9999px", 261 + boxShadow: `0 0 8px ${color}`, 262 + pointerEvents: "none", 263 + transform: "translate(-50%, -50%) scale(0.3)", 264 + transition: "transform 280ms ease-out, opacity 280ms ease-out", 265 + opacity: "1", 266 + zIndex: "9999", 267 + }); 268 + 269 + document.body.append(blast); 270 + requestAnimationFrame(() => { 271 + blast.style.transform = "translate(-50%, -50%) scale(2.8)"; 272 + blast.style.opacity = "0"; 273 + }); 274 + const timer = setTimeout(() => { 275 + effects.delete(blast); 276 + blast.remove(); 277 + }, 300); 278 + effects.set(blast, timer); 279 + }; 280 + 281 + const showCongrats = (left, top) => { 282 + const message = document.createElement("div"); 283 + message.dataset.profileFx = "congrats"; 284 + message.textContent = "Congrats!"; 285 + Object.assign(message.style, { 286 + position: "fixed", 287 + left: `${left}px`, 288 + top: `${top - 28}px`, 289 + color: "white", 290 + fontFamily: "ui-rounded, system-ui, sans-serif", 291 + fontSize: "clamp(22px, 4vw, 38px)", 292 + fontWeight: "800", 293 + letterSpacing: "0.04em", 294 + pointerEvents: "none", 295 + textShadow: "0 0 8px #7BCCE5, 0 0 18px #6D2380", 296 + transform: "translate(-50%, -50%) scale(0.5)", 297 + transition: "transform 500ms cubic-bezier(.15,.8,.25,1), opacity 600ms ease-out", 298 + opacity: "0", 299 + zIndex: "10000", 300 + }); 301 + document.body.append(message); 302 + requestAnimationFrame(() => { 303 + message.style.transform = "translate(-50%, -50%) scale(1)"; 304 + message.style.opacity = "1"; 305 + }); 306 + effects.set(message, []); 307 + }; 308 + 309 + const devourPage = (target) => { 310 + const letters = []; 311 + const textNodes = []; 312 + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, { 313 + acceptNode(node) { 314 + const parent = node.parentElement; 315 + if (!parent || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT; 316 + if (parent.closest("[data-punchcard], [data-profile-fx], script, style, noscript, textarea, select, option")) { 317 + return NodeFilter.FILTER_REJECT; 318 + } 319 + return NodeFilter.FILTER_ACCEPT; 320 + }, 321 + }); 322 + 323 + while (walker.nextNode()) { 324 + const node = walker.currentNode; 325 + const parent = node.parentElement; 326 + const text = node.nodeValue; 327 + const style = getComputedStyle(parent); 328 + const start = letters.length; 329 + 330 + for (let index = 0; index < text.length; index += 1) { 331 + if (/\s/.test(text[index])) continue; 332 + const range = document.createRange(); 333 + range.setStart(node, index); 334 + range.setEnd(node, index + 1); 335 + const rect = range.getBoundingClientRect(); 336 + if (!rect.width && !rect.height) continue; 337 + letters.push({ 338 + character: text[index], 339 + left: rect.left, 340 + top: rect.top, 341 + width: rect.width, 342 + height: rect.height, 343 + font: style.font, 344 + color: style.color, 345 + order: letters.length, 346 + }); 347 + } 348 + if (letters.length > start) textNodes.push({ node, delay: start * 18 }); 349 + } 350 + 351 + textNodes.forEach(({ node, delay }) => { 352 + const timer = setTimeout(() => { 353 + devourTimers.delete(timer); 354 + node.nodeValue = ""; 355 + }, delay); 356 + devourTimers.add(timer); 357 + }); 358 + 359 + letters.forEach((letter) => { 360 + const glyph = document.createElement("span"); 361 + const delay = letter.order * 18; 362 + glyph.dataset.profileFx = "devoured-letter"; 363 + glyph.textContent = letter.character; 364 + Object.assign(glyph.style, { 365 + position: "fixed", 366 + left: `${letter.left}px`, 367 + top: `${letter.top}px`, 368 + width: `${Math.max(1, letter.width)}px`, 369 + height: `${Math.max(1, letter.height)}px`, 370 + color: letter.color, 371 + font: letter.font, 372 + lineHeight: `${Math.max(1, letter.height)}px`, 373 + pointerEvents: "none", 374 + transformOrigin: "center", 375 + transition: "transform 1.5s cubic-bezier(.1,.8,.2,1), opacity 1.5s ease-in", 376 + zIndex: "10001", 377 + }); 378 + document.body.append(glyph); 379 + 380 + const startTimer = setTimeout(() => { 381 + devourTimers.delete(startTimer); 382 + const dx = target.screenX - (letter.left + letter.width / 2); 383 + const dy = target.screenY - (letter.top + letter.height / 2); 384 + glyph.style.transform = `translate(${dx.toFixed(1)}px, ${dy.toFixed(1)}px) scale(0.08) rotate(${(Math.random() * 720 - 360).toFixed(0)}deg)`; 385 + glyph.style.opacity = "0"; 386 + }, delay + 80); 387 + const removeTimer = setTimeout(() => { 388 + devourTimers.delete(removeTimer); 389 + effects.delete(glyph); 390 + glyph.remove(); 391 + }, delay + 1650); 392 + devourTimers.add(startTimer); 393 + devourTimers.add(removeTimer); 394 + effects.set(glyph, [startTimer, removeTimer]); 395 + }); 396 + }; 397 + 398 + const becomeSingularity = (particle) => { 399 + const gridRect = grid.getBoundingClientRect(); 400 + singularity = { 401 + screenX: gridRect.left + gridRect.width / 2 + particle.x, 402 + screenY: gridRect.top + gridRect.height / 2 + particle.y, 403 + }; 404 + mouse = { ...mouse, active: false }; 405 + particle.vx = 0; 406 + particle.vy = 0; 407 + dots[particle.index].style.visibility = "hidden"; 408 + paintBlackHole(performance.now()); 409 + devourPage(singularity); 410 + }; 411 + 412 + const showFireworks = (particle) => { 413 + const gridRect = grid.getBoundingClientRect(); 414 + const left = gridRect.left + gridRect.width / 2 + particle.x; 415 + const top = gridRect.top + gridRect.height / 2 + particle.y; 416 + 417 + const launchWave = (wave) => { 418 + const sparks = wave === 0 ? 28 : 18; 419 + for (let index = 0; index < sparks; index += 1) { 420 + const spark = document.createElement("div"); 421 + const angle = (Math.PI * 2 * index) / sparks + (Math.random() - 0.5) * 0.25; 422 + const distance = 38 + Math.random() * 85; 423 + const color = prideColors[(index + wave * 3) % prideColors.length]; 424 + Object.assign(spark.style, { 425 + position: "fixed", 426 + left: `${left}px`, 427 + top: `${top}px`, 428 + width: "4px", 429 + height: "4px", 430 + backgroundColor: color, 431 + borderRadius: "50%", 432 + boxShadow: `0 0 6px ${color}`, 433 + pointerEvents: "none", 434 + transform: "translate(-50%, -50%)", 435 + transition: "transform 1.1s cubic-bezier(.15,.8,.25,1)", 436 + zIndex: "9999", 437 + }); 438 + document.body.append(spark); 439 + requestAnimationFrame(() => { 440 + spark.style.transform = `translate(-50%, -50%) translate(${(Math.cos(angle) * distance).toFixed(1)}px, ${(Math.sin(angle) * distance).toFixed(1)}px)`; 441 + }); 442 + effects.set(spark, []); 443 + } 444 + }; 445 + 446 + playTada(); 447 + showCongrats(gridRect.left + gridRect.width / 2, gridRect.top + gridRect.height / 2); 448 + for (let wave = 0; wave < 7; wave += 1) { 449 + const timer = setTimeout(() => { 450 + fireworkTimers.delete(timer); 451 + launchWave(wave); 452 + }, wave * 800); 453 + fireworkTimers.add(timer); 454 + } 455 + }; 456 + 457 + const reset = () => { 458 + if (singularity) return; 459 + // Set every base size before measuring. Absorption only uses transform scale, 460 + // so changing a shape later cannot shift the grid beneath the physics mesh. 461 + const seeds = dots.map((dot, index) => { 462 + const radius = initialRadius(dot); 463 + return { 464 + index, 465 + baseRadius: radius, 466 + radius, 467 + mass: (radius * radius) / 3, 468 + colorIndex: Math.floor(((index % cols) * prideColors.length) / cols), 469 + shape: particleShapes[Math.floor(Math.random() * particleShapes.length)], 470 + alive: true, 471 + }; 472 + }); 473 + seeds.forEach(paintParticle); 474 + 475 + const gridRect = grid.getBoundingClientRect(); 476 + halfWidth = Math.max(1, gridRect.width / 2); 477 + halfHeight = Math.max(1, gridRect.height / 2); 478 + particles = seeds.map((particle) => { 479 + const cellRect = dots[particle.index].parentElement.getBoundingClientRect(); 480 + const originX = cellRect.left - gridRect.left + cellRect.width / 2 - halfWidth; 481 + const originY = cellRect.top - gridRect.top + cellRect.height / 2 - halfHeight; 482 + const spin = 0.25 + (Math.random() - 0.5) * 0.08; 483 + return { 484 + ...particle, 485 + originX, 486 + originY, 487 + x: originX, 488 + y: originY, 489 + vx: -originY * spin, 490 + vy: originX * spin, 491 + }; 492 + }); 493 + aliveCount = particles.length; 494 + fireworksShown = false; 495 + lastTime = 0; 496 + }; 497 + 498 + const layout = () => { 499 + cols = wideScreen.matches ? 14 : 28; 500 + dots.forEach((dot, index) => { 501 + const column = index % cols; 502 + dot.style.backgroundColor = prideColors[Math.floor((column * prideColors.length) / cols)]; 503 + dot.style.transition = "none"; 504 + dot.style.transformOrigin = "center"; 505 + dot.style.willChange = "transform"; 506 + }); 507 + reset(); 508 + }; 509 + 510 + const absorb = (winner, loser) => { 511 + const totalMass = winner.mass + loser.mass; 512 + winner.x = (winner.x * winner.mass + loser.x * loser.mass) / totalMass; 513 + winner.y = (winner.y * winner.mass + loser.y * loser.mass) / totalMass; 514 + winner.vx = (winner.vx * winner.mass + loser.vx * loser.mass) / totalMass; 515 + winner.vy = (winner.vy * winner.mass + loser.vy * loser.mass) / totalMass; 516 + winner.radius = Math.min(maxRadius, Math.hypot(winner.radius, loser.radius)); 517 + winner.mass = (winner.radius * winner.radius) / 3; 518 + winner.colorIndex = (winner.colorIndex + loser.colorIndex + 1 + Math.floor(Math.random() * 3)) % prideColors.length; 519 + winner.shape = particleShapes[Math.floor(Math.random() * particleShapes.length)]; 520 + dots[winner.index].style.backgroundColor = prideColors[winner.colorIndex]; 521 + loser.alive = false; 522 + aliveCount -= 1; 523 + paintParticle(winner); 524 + paintParticle(loser); 525 + showBlast(winner); 526 + playPop(); 527 + if (aliveCount === 1 && !fireworksShown) { 528 + fireworksShown = true; 529 + becomeSingularity(winner); 530 + showFireworks(winner); 531 + } 532 + }; 533 + 534 + const tick = (time) => { 535 + frame = 0; 536 + if (!visible) return; 537 + 538 + const seconds = lastTime ? Math.min((time - lastTime) / 1000, 1 / 30) : 0; 539 + lastTime = time; 540 + paintBlackHole(time); 541 + accelerationX.fill(0); 542 + accelerationY.fill(0); 543 + const attractionBoost = aliveCount < 5 ? 1 + ((5 - aliveCount) / 4) * 3 : 1; 544 + 545 + for (let i = 0; i < particles.length; i += 1) { 546 + const a = particles[i]; 547 + if (!a.alive) continue; 548 + 549 + if (mouse.active) { 550 + const dx = mouse.x - a.x; 551 + const dy = mouse.y - a.y; 552 + const distanceSquared = dx * dx + dy * dy + 576; 553 + const pull = 200000 / (distanceSquared * Math.sqrt(distanceSquared)); 554 + accelerationX[i] += dx * pull; 555 + accelerationY[i] += dy * pull; 556 + } 557 + 558 + for (let j = i + 1; j < particles.length; j += 1) { 559 + const b = particles[j]; 560 + if (!b.alive) continue; 561 + 562 + const dx = b.x - a.x; 563 + const dy = b.y - a.y; 564 + const collisionDistance = a.radius + b.radius; 565 + if (dx * dx + dy * dy < collisionDistance * collisionDistance) { 566 + const winner = a.radius >= b.radius ? a : b; 567 + const loser = winner === a ? b : a; 568 + absorb(winner, loser); 569 + if (loser === a) break; 570 + continue; 571 + } 572 + 573 + // Every dot pulls on every other dot; larger combined dots have more mass. 574 + const distanceSquared = dx * dx + dy * dy + 324; 575 + const pull = (560 * attractionBoost) / (distanceSquared * Math.sqrt(distanceSquared)); 576 + accelerationX[i] += dx * pull * b.mass; 577 + accelerationY[i] += dy * pull * b.mass; 578 + accelerationX[j] -= dx * pull * a.mass; 579 + accelerationY[j] -= dy * pull * a.mass; 580 + } 581 + } 582 + 583 + particles.forEach((particle, index) => { 584 + if (!particle.alive) return; 585 + 586 + const overflow = Math.max( 587 + 0, 588 + (Math.abs(particle.x) - halfWidth) / halfWidth, 589 + (Math.abs(particle.y) - halfHeight) / halfHeight, 590 + ); 591 + if (overflow > 0) { 592 + // The farther it escapes the punchcard, the stronger its pull back to centre. 593 + const pullBack = 0.12 * overflow + 0.9 * overflow * overflow; 594 + particle.vx -= particle.x * pullBack * seconds; 595 + particle.vy -= particle.y * pullBack * seconds; 596 + } 597 + 598 + particle.vx = (particle.vx + accelerationX[index] * seconds) * 0.995; 599 + particle.vy = (particle.vy + accelerationY[index] * seconds) * 0.995; 600 + particle.x += particle.vx * seconds; 601 + particle.y += particle.vy * seconds; 602 + const scale = particle.radius / particle.baseRadius; 603 + dots[index].style.transform = `translate(${(particle.x - particle.originX).toFixed(2)}px, ${(particle.y - particle.originY).toFixed(2)}px) scale(${scale.toFixed(3)})`; 604 + }); 605 + 606 + frame = requestAnimationFrame(tick); 607 + }; 608 + 609 + const resize = () => { 610 + if (resizeFrame) return; 611 + resizeFrame = requestAnimationFrame(() => { 612 + resizeFrame = 0; 613 + reset(); 614 + }); 615 + }; 616 + 617 + layout(); 618 + wideScreen.addEventListener("change", layout); 619 + window.addEventListener("resize", resize); 620 + grid.addEventListener("pointerdown", unlockAudio); 621 + grid.addEventListener("pointermove", moveMouse, { passive: true }); 622 + grid.addEventListener("pointerleave", leaveMouse); 623 + 624 + const observer = new IntersectionObserver((entries) => { 625 + visible = entries.some((entry) => entry.isIntersecting); 626 + if (visible && !frame) frame = requestAnimationFrame(tick); 627 + else if (!visible && frame) { 628 + cancelAnimationFrame(frame); 629 + frame = 0; 630 + } 631 + }); 632 + observer.observe(grid); 633 + frame = requestAnimationFrame(tick); 634 + 635 + return () => { 636 + cancelAnimationFrame(frame); 637 + observer.disconnect(); 638 + cancelAnimationFrame(resizeFrame); 639 + wideScreen.removeEventListener("change", layout); 640 + window.removeEventListener("resize", resize); 641 + grid.removeEventListener("pointerdown", unlockAudio); 642 + grid.removeEventListener("pointermove", moveMouse); 643 + grid.removeEventListener("pointerleave", leaveMouse); 644 + blackHole.remove(); 645 + fireworkTimers.forEach((timer) => clearTimeout(timer)); 646 + devourTimers.forEach((timer) => clearTimeout(timer)); 647 + effects.forEach((timers, effect) => { 648 + (Array.isArray(timers) ? timers : [timers]).forEach((timer) => clearTimeout(timer)); 649 + effect.remove(); 650 + }); 651 + if (originalGridStyle === null) grid.removeAttribute("style"); 652 + else grid.setAttribute("style", originalGridStyle); 653 + dots.forEach((dot, index) => { 654 + if (originalStyles[index] === null) dot.removeAttribute("style"); 655 + else dot.setAttribute("style", originalStyles[index]); 656 + }); 657 + }; 658 + } 659 + 660 + function mount() { 661 + const grid = document.querySelector("[data-punchcard]"); 662 + if (grid === currentGrid && stop && !reducedMotion.matches) return; 663 + 664 + if (stop) stop(); 665 + stop = null; 666 + currentGrid = grid; 667 + 668 + if (!grid || reducedMotion.matches) return; 669 + stop = animate(grid); 670 + } 671 + 672 + mount(); 673 + document.addEventListener("htmx:load", mount); 674 + reducedMotion.addEventListener("change", mount);
+2
appview/state/profile.go
··· 64 64 "did:plc:doe7nkqeodssh6uc5jcq5iyw": "/static/profile-fx/luisstd.js", 65 65 "did:plc:f2ablw5m3ashhpydt6u7h2rx": "/static/profile-fx/chancey.dev.js", 66 66 "did:plc:4aah6pidgmlp36cvtispnwhu": "/static/profile-fx/matthewlipski.tngl.sh.js", 67 + "did:plc:7qubw2z53qzfturlblaozz4a": "/static/profile-fx/mihaizaurus.at.js", 68 + "did:plc:xmyx5qvyzd4fm77oehni7pvh": "/static/profile-fx/danschmidt.js", 67 69 } 68 70 69 71 func (s *State) profile(r *http.Request) (*pages.ProfileCard, error) {