Monorepo for Tangled
0

Configure Feed

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

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

appview/pages/profile-fx/oppili.js: oppi.li <me@oppi.li>
appview/pages/profile-fx/wilb.me.js: Wilhelm Berggren <wilhelmberggren@gmail.com>
appview/pages/profile-fx/kandake.js: ashex <ashex@chipnick.com>
appview/pages/profile-fx/gdorsi.bsky.social.js: Gustavo Dorsi <gu.dorsi@gmail.com>
appview/pages/profile-fx/punchcard-td.js: J.H. Roemer <jh.roemer@gmail.com>
appview/pages/profile-fx/willow.sh.js: Willow <git@willow.sh>
appview/pages/profile-fx/luisstd.js: Luis Steidle <luis@steidle.me>
appview/pages/profile-fx/chancey.dev.js: Jonathan Chancey <jonathan@chancey.dev>
appview/pages/profile-fx/matthewlipski.tngl.sh.js: Matthew Lipski <matthewlipski@gmail.com>
appview/state/profile.go: Lewis <lewis@tangled.org>

Signed-off-by: Anirudh Oppiliappan <x@icyphox.sh>

authored by

Anirudh Oppiliappan and committed by
Anirudh Oppiliappan
(Jul 14, 2026, 8:10 PM +0300) 19ead6a1 a00289e3

+2887
+565
appview/pages/profile-fx/chancey.dev.js
··· 1 + const punchcard = document.querySelector("[data-punchcard]"); 2 + 3 + if (punchcard) { 4 + const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)"); 5 + const wideScreen = matchMedia("(min-width: 768px)"); 6 + 7 + const purple = "#b57edc"; 8 + const white = "#fff"; 9 + const idleBackground = `linear-gradient(90deg, ${purple} 0 50%, ${white} 50% 100%)`; 10 + 11 + const activityCache = new WeakMap(); 12 + 13 + let dots = []; 14 + let frame = 0; 15 + let hovered = false; 16 + let pointerX = 0.5; 17 + let pointerY = 0.5; 18 + let coinX = NaN; 19 + let coinY = NaN; 20 + let velocityX = 0; 21 + let velocityY = 0; 22 + let coinMix = 0; 23 + let evaporateStart = null; 24 + let evaporateFrom = 0; 25 + let clickSpinStart = -Infinity; 26 + let clickSpinDirection = 1; 27 + let coalesceStart = -Infinity; 28 + let explosionStart = -Infinity; 29 + let explosionPower = 1; 30 + let explosionX = 0; 31 + let explosionY = 0; 32 + let joltStart = -Infinity; 33 + let joltX = 0; 34 + let joltY = 0; 35 + let joltDirection = 1; 36 + let suppressGatherUntil = 0; 37 + let waitForReenterAfterExplosion = false; 38 + let lastTime = 0; 39 + let layout = { cols: 28, rows: 1 }; 40 + 41 + function columnCount() { 42 + return wideScreen.matches ? 14 : 28; 43 + } 44 + 45 + function clamp(value, min, max) { 46 + return Math.min(max, Math.max(min, value)); 47 + } 48 + 49 + function lerp(a, b, t) { 50 + return a + (b - a) * t; 51 + } 52 + 53 + function ease(t) { 54 + return t * t * (3 - 2 * t); 55 + } 56 + 57 + function smoothstep(edge0, edge1, value) { 58 + const t = clamp((value - edge0) / (edge1 - edge0), 0, 1); 59 + return t * t * (3 - 2 * t); 60 + } 61 + 62 + function ring(distance, front, width, energy = 1) { 63 + return (1 - smoothstep(0, width, Math.abs(distance - front))) * energy; 64 + } 65 + 66 + function heldSpin(raw, hold) { 67 + return raw - Math.sin(raw * 2) * hold * 0.5; 68 + } 69 + 70 + function occasionalSpin(now, activity, phase) { 71 + const active = 0.16; 72 + const period = lerp(9800, 5600, activity); 73 + const cycle = ((now + phase * 1400) % period) / period; 74 + 75 + if (cycle >= active) return Math.PI * 2; 76 + 77 + return ease(cycle / active) * Math.PI * 2; 78 + } 79 + 80 + function numberFromLabel(text) { 81 + const match = text?.match( 82 + /(\d+(?:\.\d+)?)\s+(?:commit|commits|contribution|contributions|change|changes)/i, 83 + ); 84 + 85 + return match ? Number(match[1]) : null; 86 + } 87 + 88 + function explicitActivity(dot, wrapper) { 89 + for (const element of [dot, wrapper]) { 90 + for (const key of ["count", "commits", "contributions", "value", "level", "intensity"]) { 91 + const value = element.dataset?.[key]; 92 + if (value !== undefined && value !== "" && !Number.isNaN(Number(value))) { 93 + return Number(value); 94 + } 95 + } 96 + 97 + const label = 98 + `${element.getAttribute("aria-label") || ""} ${element.getAttribute("title") || ""}`; 99 + const labelValue = numberFromLabel(label); 100 + if (labelValue !== null) return labelValue; 101 + 102 + const className = typeof element.className === "string" ? element.className : ""; 103 + const classValue = className.match(/(?:level|count|activity|intensity)-?(\d+)/i); 104 + if (classValue) return Number(classValue[1]); 105 + } 106 + 107 + return null; 108 + } 109 + 110 + function colorActivity(color) { 111 + const match = color.match(/rgba?\(([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:[,\s/]+([\d.]+))?\)/i); 112 + if (!match) return 0; 113 + 114 + const r = Number(match[1]); 115 + const g = Number(match[2]); 116 + const b = Number(match[3]); 117 + const a = match[4] === undefined ? 1 : Number(match[4]); 118 + if (a <= 0) return 0; 119 + 120 + const max = Math.max(r, g, b); 121 + const min = Math.min(r, g, b); 122 + const saturation = max === 0 ? 0 : (max - min) / max; 123 + const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; 124 + const greenBias = clamp((g - Math.max(r, b)) / 160, 0, 1); 125 + 126 + return clamp((saturation * 0.45 + greenBias * 0.55) * (0.55 + (1 - luminance) * 0.65), 0, 1); 127 + } 128 + 129 + function readActivity(dot, wrapper) { 130 + if (activityCache.has(dot)) return activityCache.get(dot); 131 + 132 + const signal = { 133 + explicit: explicitActivity(dot, wrapper), 134 + color: colorActivity(getComputedStyle(dot).backgroundColor), 135 + }; 136 + 137 + activityCache.set(dot, signal); 138 + return signal; 139 + } 140 + 141 + function refreshLayout() { 142 + layout.cols = columnCount(); 143 + layout.rows = Math.ceil(dots.length / layout.cols) || 1; 144 + } 145 + 146 + function setup() { 147 + const cols = columnCount(); 148 + 149 + const items = Array.from(punchcard.children) 150 + .map((wrapper, index) => { 151 + const dot = wrapper.firstElementChild; 152 + if (!dot) return null; 153 + 154 + return { 155 + dot, 156 + wrapper, 157 + signal: readActivity(dot, wrapper), 158 + col: index % cols, 159 + row: Math.floor(index / cols), 160 + phase: index * 0.43, 161 + }; 162 + }) 163 + .filter(Boolean); 164 + 165 + const maxExplicit = Math.max(0, ...items.map((item) => item.signal.explicit || 0)); 166 + const maxColor = Math.max(0.001, ...items.map((item) => item.signal.color || 0)); 167 + 168 + dots = items.map((item) => { 169 + const activity = 170 + item.signal.explicit !== null 171 + ? maxExplicit > 0 172 + ? Math.log1p(item.signal.explicit) / Math.log1p(maxExplicit) 173 + : 0 174 + : item.signal.color > 0.015 175 + ? item.signal.color / maxColor 176 + : 0; 177 + 178 + item.wrapper.style.perspective = "90px"; 179 + 180 + item.dot.style.transition = "none"; 181 + item.dot.style.borderRadius = "50%"; 182 + item.dot.style.transformOrigin = "50% 50%"; 183 + item.dot.style.backfaceVisibility = "visible"; 184 + item.dot.style.willChange = "transform, opacity, background, box-shadow, filter"; 185 + item.dot.style.background = idleBackground; 186 + 187 + return { 188 + ...item, 189 + activity: clamp(activity, 0, 1), 190 + }; 191 + }); 192 + 193 + refreshLayout(); 194 + 195 + if (!Number.isFinite(coinX)) coinX = (layout.cols - 1) / 2; 196 + if (!Number.isFinite(coinY)) coinY = (layout.rows - 1) / 2; 197 + } 198 + 199 + function radius() { 200 + return Math.max(2.8, Math.min(layout.cols * 0.3, layout.rows * 0.24)); 201 + } 202 + 203 + function bounds() { 204 + return { 205 + minX: 0, 206 + maxX: layout.cols - 1, 207 + minY: 0, 208 + maxY: layout.rows - 1, 209 + }; 210 + } 211 + 212 + function setPointer(event) { 213 + const rect = punchcard.getBoundingClientRect(); 214 + 215 + pointerX = clamp((event.clientX - rect.left) / rect.width, 0, 1); 216 + pointerY = clamp((event.clientY - rect.top) / rect.height, 0, 1); 217 + } 218 + 219 + function evaporateCoin() { 220 + evaporateStart = performance.now(); 221 + evaporateFrom = Math.max(coinMix, 0.28); 222 + 223 + const speed = Math.hypot(velocityX, velocityY); 224 + if (speed < 2.8) { 225 + const angle = speed > 0.2 ? Math.atan2(velocityY, velocityX) : -0.72; 226 + velocityX = Math.cos(angle) * 3.5; 227 + velocityY = Math.sin(angle) * 3.5; 228 + } 229 + } 230 + 231 + function triggerCoalesce(now = performance.now()) { 232 + coalesceStart = now - 90; 233 + evaporateStart = null; 234 + coinMix = Math.max(coinMix, 0.78); 235 + } 236 + 237 + function triggerSpin(now = performance.now()) { 238 + clickSpinStart = now; 239 + clickSpinDirection *= -1; 240 + evaporateStart = null; 241 + coinMix = Math.max(coinMix, 0.62); 242 + } 243 + 244 + function triggerExplosion(now = performance.now(), power = 1) { 245 + const { minX, maxX, minY, maxY } = bounds(); 246 + 247 + explosionStart = now; 248 + explosionPower = power; 249 + explosionX = Number.isFinite(coinX) ? coinX : clamp(pointerX * (layout.cols - 1), minX, maxX); 250 + explosionY = Number.isFinite(coinY) ? coinY : clamp(pointerY * (layout.rows - 1), minY, maxY); 251 + suppressGatherUntil = now + 1050 + power * 260; 252 + waitForReenterAfterExplosion = hovered; 253 + if (hovered) coalesceStart = Infinity; 254 + evaporateStart = null; 255 + evaporateFrom = Math.max(coinMix, 0.95); 256 + coinMix = evaporateFrom; 257 + 258 + const angle = Math.atan2(velocityY || -0.45, velocityX || 0.9); 259 + velocityX = Math.cos(angle) * (4.8 + power * 0.7); 260 + velocityY = Math.sin(angle) * (4.8 + power * 0.7); 261 + } 262 + 263 + function triggerJolt(now = performance.now()) { 264 + const { minX, maxX, minY, maxY } = bounds(); 265 + 266 + joltStart = now; 267 + joltX = clamp(pointerX * (layout.cols - 1), minX, maxX); 268 + joltY = clamp(pointerY * (layout.rows - 1), minY, maxY); 269 + joltDirection *= -1; 270 + } 271 + 272 + function resumeCoalesceAfterReenter(now = performance.now()) { 273 + if (!waitForReenterAfterExplosion) return false; 274 + 275 + waitForReenterAfterExplosion = false; 276 + coalesceStart = Math.max(now, suppressGatherUntil); 277 + if (now >= suppressGatherUntil) coinMix = Math.max(coinMix, 0.78); 278 + return true; 279 + } 280 + 281 + function paintStill() { 282 + for (const { dot, activity } of dots) { 283 + dot.style.background = idleBackground; 284 + dot.style.opacity = `${0.45 + activity * 0.55}`; 285 + dot.style.transform = `scale(${0.72 + activity * 0.42})`; 286 + dot.style.boxShadow = "none"; 287 + dot.style.filter = "none"; 288 + } 289 + } 290 + 291 + function animate(now) { 292 + const dt = lastTime ? clamp((now - lastTime) / 1000, 0.001, 0.04) : 0.016; 293 + lastTime = now; 294 + 295 + const r = radius(); 296 + const { minX, maxX, minY, maxY } = bounds(); 297 + const targetX = clamp(pointerX * (layout.cols - 1), minX, maxX); 298 + const targetY = clamp(pointerY * (layout.rows - 1), minY, maxY); 299 + const explosionDuration = 1050 + explosionPower * 250; 300 + const explosionT = clamp((now - explosionStart) / explosionDuration, 0, 1); 301 + const exploding = now - explosionStart >= 0 && explosionT < 1; 302 + const joltDuration = 620; 303 + const joltT = clamp((now - joltStart) / joltDuration, 0, 1); 304 + const jolting = now - joltStart >= 0 && joltT < 1; 305 + const evaporateDuration = 1650; 306 + const evaporateT = 307 + evaporateStart !== null ? clamp((now - evaporateStart) / evaporateDuration, 0, 1) : 1; 308 + const evaporating = evaporateStart !== null && evaporateT < 1; 309 + const effectiveHovered = 310 + hovered && !exploding && !waitForReenterAfterExplosion && now >= suppressGatherUntil; 311 + 312 + const previousX = coinX; 313 + const previousY = coinY; 314 + 315 + if (effectiveHovered) { 316 + evaporateStart = null; 317 + 318 + const follow = 1 - Math.exp(-9.5 * dt); 319 + coinX += (targetX - coinX) * follow; 320 + coinY += (targetY - coinY) * follow; 321 + 322 + velocityX = (coinX - previousX) / dt; 323 + velocityY = (coinY - previousY) / dt; 324 + 325 + coinMix += (1 - coinMix) * (1 - Math.exp(-18 * dt)); 326 + } else { 327 + if (exploding) { 328 + coinMix = evaporateFrom * (1 - smoothstep(0.06, 0.74, explosionT)); 329 + } else if (waitForReenterAfterExplosion) { 330 + coinMix = 0; 331 + evaporateStart = null; 332 + } else if (evaporating) { 333 + const dissolve = smoothstep(0.04, 0.96, evaporateT); 334 + coinMix = evaporateFrom * (1 - dissolve); 335 + if (evaporateT >= 1) evaporateStart = null; 336 + } else if (evaporateStart !== null) { 337 + coinMix = 0; 338 + evaporateStart = null; 339 + } else { 340 + coinMix += (0 - coinMix) * (1 - Math.exp(-3.5 * dt)); 341 + } 342 + 343 + if (coinMix > 0.01) { 344 + coinX += velocityX * dt; 345 + coinY += velocityY * dt; 346 + 347 + if (coinX < minX || coinX > maxX) { 348 + coinX = clamp(coinX, minX, maxX); 349 + velocityX *= -0.9; 350 + } 351 + 352 + if (coinY < minY || coinY > maxY) { 353 + coinY = clamp(coinY, minY, maxY); 354 + velocityY *= -0.9; 355 + } 356 + 357 + const driftDamping = Math.exp(-0.22 * dt); 358 + velocityX *= driftDamping; 359 + velocityY *= driftDamping; 360 + } 361 + } 362 + 363 + coinX = clamp(coinX, minX, maxX); 364 + coinY = clamp(coinY, minY, maxY); 365 + 366 + const mix = ease(coinMix); 367 + const rippleEnergy = Math.sin(mix * Math.PI); 368 + const gridReach = Math.hypot(layout.cols, layout.rows) + r; 369 + const coalesceDuration = 520; 370 + const coalesceT = clamp((now - coalesceStart) / coalesceDuration, 0, 1); 371 + const coalescing = effectiveHovered && now - coalesceStart >= 0 && coalesceT < 1; 372 + const coalesceEnergy = coalescing ? Math.pow(1 - coalesceT, 0.38) : 0; 373 + 374 + const hopCycle = (now / 1580) % 1; 375 + const hopArc = Math.sin(hopCycle * Math.PI); 376 + const hopLift = Math.pow(hopArc, 0.86) * Math.min(1.15, r * 0.24) * mix; 377 + 378 + const centerX = coinX; 379 + const centerY = clamp(coinY - hopLift, minY, maxY); 380 + 381 + const clickSpinT = clamp((now - clickSpinStart) / 820, 0, 1); 382 + const clickSpinActive = now - clickSpinStart >= 0 && clickSpinT < 1; 383 + const clickSpinEase = 1 - Math.pow(1 - clickSpinT, 3); 384 + const clickSpinPop = clickSpinActive ? Math.sin(clickSpinT * Math.PI) : 0; 385 + const clickSpin = clickSpinActive ? clickSpinDirection * Math.PI * 6 * clickSpinEase : 0; 386 + 387 + const rawSpin = hopCycle * Math.PI * 2 + clickSpin; 388 + const spin = heldSpin(rawSpin, lerp(0.64, 0.18, clickSpinPop)); 389 + const face = Math.abs(Math.cos(spin)); 390 + const faceHold = Math.pow(face, 0.38); 391 + const edgeFlash = 1 - face; 392 + const widthScale = 0.2 + faceHold * 0.8; 393 + const heightScale = 1 + edgeFlash * 0.06; 394 + const flipped = Math.cos(spin) < 0; 395 + const coinBrightness = 0.94 + faceHold * 0.13 + edgeFlash * 0.12 + clickSpinPop * 0.16; 396 + const explosionReach = (gridReach + r) * (0.92 + explosionPower * 0.12); 397 + const explosionFront = explosionT * explosionReach - r * 0.35; 398 + const explosionEnergy = exploding ? Math.pow(1 - explosionT, 0.55) * explosionPower : 0; 399 + const joltReach = layout.cols + layout.rows; 400 + const joltFront = joltT * joltReach - 1; 401 + const joltEnergy = jolting ? Math.pow(1 - joltT, 0.65) : 0; 402 + const evaporateFront = evaporateT * gridReach - r * 0.2; 403 + const evaporateEnergy = evaporating ? Math.pow(1 - evaporateT, 0.42) : 0; 404 + const coalesceFront = (1 - coalesceT) * gridReach; 405 + 406 + for (const item of dots) { 407 + const { dot, col, row, phase, activity } = item; 408 + 409 + const idleSpin = heldSpin(occasionalSpin(now, activity, phase), 0.72); 410 + const idleFace = Math.pow(Math.abs(Math.cos(idleSpin)), 0.42); 411 + const idleScale = 0.66 + activity * 0.48 + idleFace * (0.04 + activity * 0.05); 412 + const idleOpacity = 0.42 + activity * 0.58; 413 + const idleGlow = (1 - idleFace) * (0.07 + activity * 0.22); 414 + 415 + const localX = (col - centerX) / widthScale; 416 + const localY = (row - centerY) / heightScale; 417 + const distance = Math.hypot(localX, localY); 418 + const coinShape = 1 - smoothstep(r - 0.65, r + 0.35, distance); 419 + const coinMass = mix * coinShape; 420 + 421 + const fieldDistance = Math.hypot(col - centerX, row - centerY); 422 + const ripple = Math.sin(fieldDistance * 1.15 - now * 0.0065) * rippleEnergy; 423 + const transferFront = mix * gridReach; 424 + const coalesceRing = coalescing ? ring(fieldDistance, coalesceFront, 2.5, coalesceEnergy) : 0; 425 + const coalesceAbsorb = 426 + coalescing 427 + ? smoothstep(coalesceFront - 1.8, coalesceFront + 1.8, fieldDistance) 428 + : 0; 429 + const fieldAbsorb = 430 + coalescing 431 + ? coalesceAbsorb 432 + : mix > 0.94 433 + ? 1 434 + : 1 - smoothstep(transferFront - 2.2, transferFront + 2.2, fieldDistance); 435 + const transfer = clamp(mix * (coinShape + (1 - coinShape) * fieldAbsorb), 0, 1); 436 + const idleMass = clamp(1 - transfer, 0, 1); 437 + const totalMass = idleMass + coinMass; 438 + 439 + const explosionDistance = Math.hypot(col - explosionX, row - explosionY); 440 + const explosionRing = exploding ? ring(explosionDistance, explosionFront, 2.4, explosionEnergy) : 0; 441 + const joltDistance = 442 + joltDirection > 0 443 + ? col - joltX + (row - joltY) * 0.45 444 + : joltX - col + (row - joltY) * 0.45; 445 + const joltRing = jolting ? ring(joltDistance, joltFront, 1.1, joltEnergy) : 0; 446 + const explosionAfterglow = 447 + exploding 448 + ? (1 - smoothstep(explosionFront - 3.4, explosionFront + 0.2, explosionDistance)) * 449 + explosionEnergy 450 + : 0; 451 + const evaporateRing = 452 + evaporating ? ring(fieldDistance, evaporateFront, 2.1, evaporateEnergy * coinShape) : 0; 453 + const evaporateSpark = 454 + evaporating 455 + ? Math.max(0, Math.sin(phase * 11.3 + evaporateT * 34)) * evaporateEnergy * coinShape 456 + : 0; 457 + 458 + const edge = distance / r; 459 + const rim = edge > 0.78; 460 + const leftHalf = flipped ? localX > 0 : localX < 0; 461 + const onSeam = Math.abs(localX) < 0.38 && edge < 0.88; 462 + 463 + const coinBackground = onSeam 464 + ? `linear-gradient(90deg, ${purple}, ${white})` 465 + : leftHalf 466 + ? purple 467 + : white; 468 + 469 + const explosionBackground = Math.sin(phase + explosionT * 22) > 0 ? purple : white; 470 + const coinScale = (rim ? 1.5 : 1.28) + clickSpinPop * (rim ? 0.16 : 0.1); 471 + const idleWeightedScale = idleScale + ripple * idleMass * 0.035; 472 + const scale = 473 + totalMass > 0.001 474 + ? (idleWeightedScale * idleMass + coinScale * coinMass) / totalMass 475 + : idleWeightedScale; 476 + const spinAmount = idleSpin; 477 + const burstRing = Math.max(explosionRing, coalesceRing, evaporateRing, joltRing); 478 + const burstScale = 479 + explosionRing * (0.42 + activity * 0.22 + clickSpinPop * 0.12) + 480 + joltRing * 0.14 + 481 + coalesceRing * (0.3 + activity * 0.18) + 482 + evaporateRing * 0.38 + 483 + evaporateSpark * 0.22; 484 + const burstOpacity = 485 + explosionRing * 0.95 + 486 + explosionAfterglow * 0.2 + 487 + joltRing * 0.36 + 488 + coalesceRing * 0.75 + 489 + evaporateRing * 0.7 + 490 + evaporateSpark * 0.38; 491 + 492 + dot.style.background = 493 + burstRing > Math.max(coinMass, idleMass) * 0.28 494 + ? explosionBackground 495 + : coinMass > idleMass * 0.72 496 + ? coinBackground 497 + : idleBackground; 498 + dot.style.opacity = `${clamp(idleOpacity * idleMass + coinMass + burstOpacity, 0, 1)}`; 499 + dot.style.transform = `rotateY(${spinAmount}rad) scale(${scale + burstScale})`; 500 + dot.style.filter = `brightness(${lerp(0.9 + idleFace * 0.13 + activity * 0.08, coinBrightness, coinMass) + burstRing * 0.45 + evaporateSpark * 0.25}) saturate(${lerp(1, 1.1, coinMass) + burstRing * 0.18})`; 501 + dot.style.boxShadow = 502 + burstRing > 0.16 503 + ? `0 0 ${8 + burstRing * 14}px rgba(181, 126, 220, ${0.22 + burstRing * 0.32})` 504 + : coinMass > 0.2 505 + ? rim 506 + ? "0 0 8px rgba(181, 126, 220, 0.3)" 507 + : "0 0 4px rgba(181, 126, 220, 0.18)" 508 + : `0 0 ${idleGlow * 8}px rgba(181, 126, 220, ${idleGlow})`; 509 + } 510 + 511 + frame = requestAnimationFrame(animate); 512 + } 513 + 514 + function start() { 515 + if (frame) cancelAnimationFrame(frame); 516 + 517 + frame = 0; 518 + lastTime = 0; 519 + setup(); 520 + 521 + if (reduceMotion.matches) { 522 + paintStill(); 523 + } else { 524 + frame = requestAnimationFrame(animate); 525 + } 526 + } 527 + 528 + punchcard.addEventListener("pointerenter", (event) => { 529 + const wasHovered = hovered; 530 + hovered = true; 531 + setPointer(event); 532 + const resumed = !wasHovered && resumeCoalesceAfterReenter(); 533 + if (!wasHovered && !resumed) triggerCoalesce(); 534 + }); 535 + 536 + punchcard.addEventListener("pointermove", setPointer); 537 + 538 + punchcard.addEventListener("pointerleave", () => { 539 + hovered = false; 540 + evaporateCoin(); 541 + }); 542 + 543 + punchcard.addEventListener("click", (event) => { 544 + const now = performance.now(); 545 + 546 + setPointer(event); 547 + if (waitForReenterAfterExplosion) { 548 + triggerJolt(now); 549 + return; 550 + } 551 + 552 + if (event.detail % 3 === 0) { 553 + triggerSpin(now); 554 + triggerExplosion(now, 1.75); 555 + } else { 556 + triggerExplosion(now, 1); 557 + } 558 + }); 559 + 560 + start(); 561 + 562 + wideScreen.addEventListener("change", start); 563 + reduceMotion.addEventListener("change", start); 564 + addEventListener("resize", refreshLayout); 565 + }
+313
appview/pages/profile-fx/gdorsi.bsky.social.js
··· 1 + // Super Mario Bros. World 1-1, rendered onto the punchcard. 2 + // 3 + // The punchcard is a grid of small dots (one per day of the year). We treat it 4 + // as a low-res dot-matrix display and side-scroll World 1-1 across it: ground, 5 + // bricks, ? blocks, pipes, Goombas, hills, bushes, clouds, a flagpole and a 6 + // castle, with Mario auto-running and hopping over the obstacles. It loops. 7 + // 8 + // Textures are base64: every sprite is an array of strings, and each character 9 + // is one pixel whose value is its index in the base64 alphabet (A=0, B=1, ...). 10 + // That index selects an entry from PAL below. Index 0 (`A`) is transparent. 11 + 12 + const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; 13 + 14 + // Palette. Index lines up with the base64 alphabet so a texture char maps 15 + // straight to a colour: A->sky, D->ground, M->mario-red, and so on. 16 + const PAL = [ 17 + null, // 0 A transparent 18 + "#5c94fc", // 1 B sky 19 + "#ffffff", // 2 C white (clouds, flag) 20 + "#c84c0c", // 3 D ground 21 + "#8a3b08", // 4 E ground / seam dark 22 + "#e39d5b", // 5 F ground top light / block edge 23 + "#b8560f", // 6 G brick 24 + "#fac000", // 7 H ? block yellow 25 + "#7a3b08", // 8 I shadow / mortar 26 + "#00a800", // 9 J pipe green 27 + "#5fdd5f", // 10 K pipe light 28 + "#006000", // 11 L pipe dark 29 + "#e21b0c", // 12 M mario red 30 + "#ffa060", // 13 N mario skin 31 + "#2038ec", // 14 O mario overalls blue 32 + "#6a2a00", // 15 P brown (mario hair, goomba feet) 33 + "#b06a3c", // 16 Q goomba tan 34 + "#000000", // 17 R black 35 + "#3ca03c", // 18 S hill / bush green 36 + "#78d060", // 19 T hill light 37 + "#b0b0b0", // 20 U castle grey 38 + "#707070", // 21 V castle dark 39 + ]; 40 + const SKY = PAL[1]; 41 + 42 + // Decode a base64 texture (array of rows) into a {w,h,d} sprite of palette ids. 43 + const spr = (rows) => ({ 44 + w: rows[0].length, 45 + h: rows.length, 46 + d: rows.map((r) => Array.prototype.map.call(r, (c) => B64.indexOf(c))), 47 + }); 48 + 49 + const HILL = spr(["AASAA", "ASTSA", "SSSSS"]); 50 + const BUSH = spr(["ASSSA", "SSSSS"]); 51 + const CLOUD = spr(["ACCCA", "CCCCC"]); 52 + 53 + // Mario's three fixed top rows (hat, face, body); the fourth row is his legs, 54 + // swapped per animation frame below. 55 + const MTOP = ["AMM", "PNN", "MMM"]; 56 + const LEGS = ["OAO", "AOO", "OAO", "OOA"]; // running cycle 57 + const LEGS_AIR = "OOA"; // tucked while jumping 58 + 59 + // --- Level layout (one looping period, measured in dot-columns) -------------- 60 + const P = 72; // period width 61 + const SCROLL = 5.5; // dot-columns per second 62 + 63 + const CLOUDS = [ 64 + { x: 8, f: 0.18 }, 65 + { x: 26, f: 0.1 }, 66 + { x: 42, f: 0.24 }, 67 + { x: 62, f: 0.14 }, 68 + ]; 69 + const HILLS = [3, 46]; 70 + const BUSHES = [14, 58]; 71 + const QSINGLE = [{ x: 10, u: 4 }]; // lone ? block 72 + const HIGHQ = { x: 21, u: 8 }; // high ? block 73 + const RUN = [ 74 + // the classic brick / ? / brick / ? / brick row, 4 tiles up 75 + { x: 18, t: "b" }, 76 + { x: 19, t: "q" }, 77 + { x: 20, t: "b" }, 78 + { x: 21, t: "q" }, 79 + { x: 22, t: "b" }, 80 + ]; 81 + const PIPES = [ 82 + { x: 28, h: 2 }, 83 + { x: 34, h: 3 }, 84 + { x: 48, h: 4 }, 85 + { x: 56, h: 2 }, 86 + ]; 87 + const PITS = [[44, 45]]; // inclusive column ranges with no ground 88 + const GOOMBAS = [24, 40, 52]; // spawn columns; they walk left 89 + const STAIRS = [ 90 + { x: 61, h: 1 }, 91 + { x: 62, h: 2 }, 92 + { x: 63, h: 3 }, 93 + { x: 64, h: 4 }, 94 + ]; 95 + const FLAGX = 68; 96 + const CASTLEX = 70; 97 + 98 + // Scheduled jumps (column ranges + peak height) that carry Mario over each 99 + // obstacle. Between them he runs along the ground. 100 + const JUMPS = [ 101 + { a: 25, b: 31, p: 4 }, // pipe @28 102 + { a: 31.5, b: 38, p: 5 }, // pipe @34 103 + { a: 42.5, b: 52, p: 7 }, // pit @44-45 + pipe @48 104 + { a: 53.5, b: 59, p: 4 }, // pipe @56 105 + { a: 60, b: 65, p: 5 }, // staircase 106 + ]; 107 + 108 + const isPit = (local) => PITS.some(([a, b]) => local >= a && local <= b); 109 + const jumpY = (local) => { 110 + let y = 0; 111 + for (const j of JUMPS) { 112 + if (local > j.a && local < j.b) { 113 + const u = (local - j.a) / (j.b - j.a); 114 + const h = 4 * j.p * u * (1 - u); // parabola, peak at the middle 115 + if (h > y) y = h; 116 + } 117 + } 118 + return y; 119 + }; 120 + 121 + // --- Renderer ---------------------------------------------------------------- 122 + const run = (card) => { 123 + const dots = Array.from(card.children, (c) => c.firstElementChild).filter(Boolean); 124 + const count = dots.length; 125 + if (!count) return () => {}; 126 + 127 + const cols = matchMedia("(min-width: 768px)").matches ? 14 : 28; 128 + const rows = Math.max(1, Math.ceil(count / cols)); 129 + const W = cols; 130 + const G = Math.max(2, Math.round(rows * 0.16)); // ground thickness 131 + const horizon = rows - G; // first ground row; sky is above 132 + const MSC = Math.max(2, Math.round(W * 0.32)); // Mario's fixed screen column 133 + const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches; 134 + 135 + // Fill the cells so the picture reads as a solid dot-matrix screen. 136 + for (const d of dots) { 137 + d.style.transition = "none"; 138 + d.style.transform = "scale(2)"; 139 + d.style.border = "none"; 140 + d.style.willChange = "background-color"; 141 + } 142 + 143 + const buf = new Uint8Array(W * rows); 144 + const last = new Array(count).fill(null); 145 + 146 + const setPx = (x, y, idx) => { 147 + if (idx > 0 && x >= 0 && x < W && y >= 0 && y < rows) buf[y * W + x] = idx; 148 + }; 149 + const blit = (s, x, y) => { 150 + for (let ry = 0; ry < s.h; ry++) { 151 + const row = s.d[ry]; 152 + for (let cx = 0; cx < s.w; cx++) setPx(x + cx, y + ry, row[cx]); 153 + } 154 + }; 155 + const blitStr = (str, x, y) => { 156 + for (let k = 0; k < str.length; k++) setPx(x + k, y, B64.indexOf(str[k])); 157 + }; 158 + 159 + // Draw one instance of a level feature per visible period. 160 + let camCol = 0; 161 + const eachBase = (cb) => { 162 + const start = Math.floor((camCol - 8) / P) * P; 163 + for (let b = start; b <= camCol + W + 8; b += P) cb(b); 164 + }; 165 + const at = (lx, draw) => 166 + eachBase((b) => { 167 + const sx = b + lx - camCol; 168 + if (sx > -8 && sx < W + 2) draw(Math.round(sx)); 169 + }); 170 + 171 + const drawPipe = (sx, h) => { 172 + const top = horizon - h; 173 + for (let y = top; y <= horizon - 1; y++) { 174 + setPx(sx, y, 10); 175 + setPx(sx + 1, y, 9); 176 + setPx(sx + 2, y, 11); 177 + } 178 + setPx(sx + 1, top, 10); // brighten the cap lip 179 + }; 180 + const drawStep = (sx, h) => { 181 + for (let y = horizon - h; y <= horizon - 1; y++) setPx(sx, y, y === horizon - h ? 5 : 3); 182 + }; 183 + const drawFlag = (sx) => { 184 + const top = horizon - 9; 185 + for (let y = top; y <= horizon - 1; y++) setPx(sx, y, 20); 186 + setPx(sx, top - 1, 2); // ball 187 + setPx(sx - 1, top, 18); 188 + setPx(sx - 2, top + 1, 18); 189 + setPx(sx - 1, top + 1, 18); 190 + setPx(sx - 1, top + 2, 18); 191 + }; 192 + const drawCastle = (sx) => { 193 + const top = horizon - 4; 194 + for (let ry = 0; ry < 4; ry++) { 195 + for (let cx = 0; cx < 5; cx++) { 196 + if (ry === 0 && cx % 2 === 1) continue; // crenellations 197 + setPx(sx + cx, top + ry, 20); 198 + } 199 + } 200 + setPx(sx + 2, horizon - 1, 17); // door 201 + setPx(sx + 2, horizon - 2, 17); 202 + setPx(sx + 1, top + 1, 17); // windows 203 + setPx(sx + 3, top + 1, 17); 204 + }; 205 + 206 + let marioY = 0; // set each frame, read by the Goomba stomp check 207 + const drawGoomba = (sx, t) => { 208 + if ((sx === MSC || sx === MSC + 1) && marioY <= 1) { 209 + blitStr("PPP", sx, horizon - 1); // squished flat 210 + return; 211 + } 212 + blitStr("QQQ", sx, horizon - 2); 213 + blitStr(Math.floor(t / 200) % 2 ? "APA" : "PAP", sx, horizon - 1); // waddle 214 + }; 215 + 216 + const render = (t) => { 217 + const camX = (t / 1000) * SCROLL; 218 + camCol = Math.floor(camX); 219 + buf.fill(1); // sky 220 + 221 + for (const c of CLOUDS) at(c.x, (sx) => blit(CLOUD, sx, Math.round(horizon * c.f))); 222 + for (const hx of HILLS) at(hx, (sx) => blit(HILL, sx, horizon - HILL.h)); 223 + for (const bx of BUSHES) at(bx, (sx) => blit(BUSH, sx, horizon - BUSH.h)); 224 + 225 + // Ground, per screen column, with a pit here and there. 226 + for (let x = 0; x < W; x++) { 227 + const local = ((camCol + x) % P + P) % P; 228 + if (isPit(local)) continue; 229 + const seam = local % 4 === 0; 230 + setPx(x, horizon, seam ? 4 : 5); 231 + for (let y = horizon + 1; y < rows; y++) setPx(x, y, (local + y) % 4 === 0 ? 4 : 3); 232 + } 233 + 234 + const blink = Math.floor(t / 350) % 4 === 0; 235 + for (const p of PIPES) at(p.x, (sx) => drawPipe(sx, p.h)); 236 + for (const q of QSINGLE) at(q.x, (sx) => setPx(sx, horizon - q.u, blink ? 5 : 7)); 237 + at(HIGHQ.x, (sx) => setPx(sx, horizon - HIGHQ.u, blink ? 5 : 7)); 238 + for (const r of RUN) at(r.x, (sx) => setPx(sx, horizon - 4, r.t === "q" ? (blink ? 5 : 7) : 6)); 239 + for (const s of STAIRS) at(s.x, (sx) => drawStep(sx, s.h)); 240 + at(FLAGX, drawFlag); 241 + at(CASTLEX, drawCastle); 242 + 243 + // Goombas walk left; a full period of travel loops seamlessly. 244 + const phase = ((t / 1000) * 3) % P; 245 + eachBase((b) => { 246 + for (const s of GOOMBAS) { 247 + const sx = Math.round(b + s - phase - camCol); 248 + if (sx > -3 && sx < W + 1) drawGoomba(sx, t); 249 + } 250 + }); 251 + 252 + // Mario, pinned to screen column MSC, hopping the level as it scrolls by. 253 + const local = ((camCol + MSC) % P + P) % P; 254 + marioY = Math.min(horizon - 4, Math.round(jumpY(local))); 255 + const legs = marioY > 0 ? LEGS_AIR : LEGS[Math.floor(t / 90) % LEGS.length]; 256 + const topY = horizon - 1 - marioY - 3; 257 + const body = [MTOP[0], MTOP[1], MTOP[2], legs]; 258 + for (let r = 0; r < 4; r++) blitStr(body[r], MSC, topY + r); 259 + 260 + // Commit only the dots that changed. 261 + for (let i = 0; i < count; i++) { 262 + const hex = PAL[buf[(i / W | 0) * W + (i % W)]] || SKY; 263 + if (hex !== last[i]) { 264 + dots[i].style.backgroundColor = hex; 265 + last[i] = hex; 266 + } 267 + } 268 + }; 269 + 270 + if (reduce) { 271 + render(2600); // one static frame, no animation 272 + return () => {}; 273 + } 274 + 275 + let raf = 0; 276 + let running = false; 277 + const loop = (t) => { 278 + render(t); 279 + raf = requestAnimationFrame(loop); 280 + }; 281 + const start = () => { 282 + if (running) return; 283 + running = true; 284 + raf = requestAnimationFrame(loop); 285 + }; 286 + const stop = () => { 287 + running = false; 288 + cancelAnimationFrame(raf); 289 + }; 290 + 291 + // Only animate while the punchcard is on screen. 292 + const io = new IntersectionObserver((es) => (es.some((e) => e.isIntersecting) ? start() : stop())); 293 + io.observe(card); 294 + start(); 295 + 296 + return () => { 297 + stop(); 298 + io.disconnect(); 299 + }; 300 + }; 301 + 302 + // Boot, and re-bind across htmx navigations (matching the reference effect). 303 + let target = null; 304 + let teardown = null; 305 + const boot = () => { 306 + const el = document.querySelector("[data-punchcard]"); 307 + if (el === target) return; 308 + if (teardown) teardown(); 309 + target = el; 310 + teardown = el ? run(el) : null; 311 + }; 312 + boot(); 313 + document.addEventListener("htmx:load", boot);
+236
appview/pages/profile-fx/kandake.js
··· 1 + // neon-terrain.js — profile fx: renders the punchcard as a polycss-style 3D 2 + // terrain (perspective + rotateX/rotateZ + translateZ elevation, as in the 3 + // terrain demo) lit with twinkl "y2kringe" neon — cyan/magenta pulses sweeping 4 + // across the grid. One self-contained ES module, vanilla JS, no imports. 5 + // 6 + // CSS is loaded by injecting a <style> element via the DOM at runtime, so the 7 + // module stays a single file with no build step. 8 + 9 + const STYLE_ID = "npx-neon-terrain-style"; 10 + const GRID_CLASS = "npx-grid"; 11 + 12 + // twinkl y2kringe palette 13 + const VOID_BG = "#0a001a"; 14 + const HUE_CYAN = 185; 15 + const HUE_MAGENTA = 300; 16 + 17 + const WIDE_Q = "(min-width: 768px)"; 18 + const REDUCED_Q = "(prefers-reduced-motion: reduce)"; 19 + const COLS_NARROW = 28; 20 + const COLS_WIDE = 14; 21 + 22 + const MAX_LIFT = 22; // px of translateZ at full energy 23 + const WAVE_SPEED = 1.7; // rad/s of the diagonal pulse 24 + const SHIMMER_SPEED = 0.6; 25 + const ROT_SPEED = 9; // deg/s turntable rotation of the whole terrain 26 + const TILT = 55; // deg rotateX of the board 27 + const BOARD_SCALE = 0.72; 28 + const QUANT = 48; // energy quantization levels — skip redundant style writes 29 + 30 + const clamp01 = (n) => Math.max(0, Math.min(1, n)); 31 + 32 + const css = ` 33 + [data-punchcard].${GRID_CLASS} { 34 + transform-style: preserve-3d; 35 + background-color: ${VOID_BG}; 36 + background-image: 37 + radial-gradient(1px 1px at 10% 20%, rgb(255 255 255 / 0.5) 0%, transparent 100%), 38 + radial-gradient(1px 1px at 30% 80%, rgb(255 255 255 / 0.4) 0%, transparent 100%), 39 + radial-gradient(1.5px 1.5px at 65% 85%, rgb(255 255 255 / 0.45) 0%, transparent 100%), 40 + radial-gradient(1px 1px at 85% 15%, rgb(255 255 255 / 0.4) 0%, transparent 100%), 41 + radial-gradient(1.5px 1.5px at 15% 55%, rgb(255 255 255 / 0.5) 0%, transparent 100%); 42 + background-size: 200px 200px; 43 + border-radius: 12px; 44 + box-shadow: 0 0 24px rgb(255 0 255 / 0.25), inset 0 0 40px rgb(0 255 255 / 0.06); 45 + } 46 + [data-punchcard].${GRID_CLASS} > * { 47 + transform-style: preserve-3d; 48 + position: relative; 49 + } 50 + /* ground shadow under each lifted dot — cheap depth cue at floor level */ 51 + [data-punchcard].${GRID_CLASS} > *::after { 52 + content: ""; 53 + position: absolute; 54 + inset: 20%; 55 + border-radius: 50%; 56 + background: rgb(255 0 255 / 0.14); 57 + filter: blur(1.5px); 58 + transform: translateZ(0.5px); 59 + pointer-events: none; 60 + } 61 + [data-punchcard].${GRID_CLASS} > * > :first-child { 62 + transform-style: preserve-3d; 63 + transition: none; 64 + will-change: transform, background-color, box-shadow; 65 + } 66 + `; 67 + 68 + const injectStyle = () => { 69 + let el = document.getElementById(STYLE_ID); 70 + if (!el) { 71 + el = document.createElement("style"); 72 + el.id = STYLE_ID; 73 + el.textContent = css; 74 + document.head.appendChild(el); 75 + } 76 + return el; 77 + }; 78 + 79 + // Luminance of the dot's current color -> base terrain height, so activity 80 + // level (darker/lighter contribution dots) becomes elevation. 81 + const luminance = (el) => { 82 + const m = getComputedStyle(el).backgroundColor.match(/[\d.]+/g); 83 + if (!m || m.length < 3) return 0; 84 + const [r, g, b] = m.map(Number); 85 + return clamp01((0.2126 * r + 0.7152 * g + 0.0722 * b) / 255); 86 + }; 87 + 88 + const collectDots = (grid) => 89 + Array.from(grid.children, (c) => c.firstElementChild).filter(Boolean); 90 + 91 + const clearDot = (el) => { 92 + el.style.backgroundColor = ""; 93 + el.style.transform = ""; 94 + el.style.boxShadow = ""; 95 + el.style.transition = ""; 96 + el.style.transformOrigin = ""; 97 + el.style.willChange = ""; 98 + }; 99 + 100 + const paintDot = (el, energy, hueMix) => { 101 + const hue = HUE_CYAN + (HUE_MAGENTA - HUE_CYAN) * hueMix; 102 + const light = 55 + energy * 20; 103 + const color = `hsl(${hue.toFixed(1)} 100% ${light.toFixed(1)}%)`; 104 + const z = (energy * MAX_LIFT).toFixed(2); 105 + const s = (1 + energy * 1.2).toFixed(3); 106 + el.style.backgroundColor = color; 107 + el.style.transform = `translateZ(${z}px) scale(${s})`; 108 + el.style.boxShadow = 109 + `0 0 ${(2 + energy * 8).toFixed(1)}px ${color}, ` + 110 + `0 0 ${(6 + energy * 16).toFixed(1)}px hsl(${hue.toFixed(1)} 100% 50% / 0.55)`; 111 + }; 112 + 113 + const start = (grid) => { 114 + const ac = new AbortController(); 115 + const opts = { signal: ac.signal }; 116 + const wideMq = matchMedia(WIDE_Q); 117 + const reduced = matchMedia(REDUCED_Q).matches; 118 + 119 + const styleEl = injectStyle(); 120 + grid.classList.add(GRID_CLASS); 121 + 122 + const boardTransform = (deg) => 123 + `perspective(1200px) rotateX(${TILT}deg) rotateZ(${deg.toFixed(2)}deg) scale(${BOARD_SCALE})`; 124 + grid.style.transform = boardTransform(45); 125 + 126 + let dots = collectDots(grid); 127 + // Inline overrides beat any Tailwind transition/duration classes on the dots. 128 + dots.forEach((el) => { 129 + el.style.transition = "none"; 130 + el.style.transformOrigin = "center"; 131 + el.style.willChange = "transform, background-color, box-shadow"; 132 + }); 133 + // Base heights are read from the pre-fx dot colors, before we repaint them. 134 + // In dark mode empty dots are darker than active ones; in light mode it is 135 + // inverted, so normalize against the dominant (most common) luminance. 136 + let base = dots.map(luminance); 137 + { 138 + const counts = new Map(); 139 + base.forEach((l) => { 140 + const k = Math.round(l * 20); 141 + counts.set(k, (counts.get(k) || 0) + 1); 142 + }); 143 + const idle = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] / 20 ?? 0; 144 + base = base.map((l) => clamp01(Math.abs(l - idle) * 1.6)); 145 + } 146 + 147 + let cols = wideMq.matches ? COLS_WIDE : COLS_NARROW; 148 + let lastQ = new Int16Array(dots.length).fill(-1); 149 + let lastHue = new Float32Array(dots.length).fill(-1); 150 + 151 + const relayout = () => { 152 + cols = wideMq.matches ? COLS_WIDE : COLS_NARROW; 153 + lastQ.fill(-1); 154 + }; 155 + wideMq.addEventListener("change", relayout, opts); 156 + 157 + const frame = (t) => { 158 + // Turntable: rotate the entire terrain around its Z axis. 159 + grid.style.transform = boardTransform(45 + t * ROT_SPEED); 160 + for (let i = 0; i < dots.length; i++) { 161 + const col = i % cols; 162 + const row = (i / cols) | 0; 163 + const wave = 0.5 + 0.5 * Math.sin(t * WAVE_SPEED - (col + row) * 0.35); 164 + const shimmer = 0.5 + 0.5 * Math.sin(t * SHIMMER_SPEED + col * 0.5 - row * 0.3); 165 + const energy = clamp01(0.12 + base[i] * 0.5 + wave * 0.45); 166 + const q = Math.round(energy * QUANT); 167 + const hueQ = Math.round(shimmer * 24) / 24; 168 + if (q === lastQ[i] && hueQ === lastHue[i]) continue; 169 + lastQ[i] = q; 170 + lastHue[i] = hueQ; 171 + paintDot(dots[i], q / QUANT, hueQ); 172 + } 173 + }; 174 + 175 + let raf = 0; 176 + let visible = true; 177 + 178 + const loop = (ms) => { 179 + frame(ms / 1000); 180 + if (visible) raf = requestAnimationFrame(loop); 181 + }; 182 + 183 + if (reduced) { 184 + // Static 3D render: elevation from activity, fixed neon tint, no motion. 185 + dots.forEach((el, i) => { 186 + const col = i % cols; 187 + const row = (i / cols) | 0; 188 + paintDot(el, clamp01(0.15 + base[i] * 0.85), ((col + row) % 8) / 8); 189 + }); 190 + wideMq.addEventListener( 191 + "change", 192 + () => dots.forEach((el, i) => paintDot(el, clamp01(0.15 + base[i] * 0.85), (((i % cols) + ((i / cols) | 0)) % 8) / 8)), 193 + opts, 194 + ); 195 + } else { 196 + const io = new IntersectionObserver((entries) => { 197 + const vis = entries.some((e) => e.isIntersecting); 198 + if (vis === visible) return; 199 + visible = vis; 200 + if (visible) raf = requestAnimationFrame(loop); 201 + else cancelAnimationFrame(raf); 202 + }); 203 + io.observe(grid); 204 + raf = requestAnimationFrame(loop); 205 + ac.signal.addEventListener("abort", () => { 206 + cancelAnimationFrame(raf); 207 + io.disconnect(); 208 + }); 209 + } 210 + 211 + return () => { 212 + ac.abort(); 213 + grid.classList.remove(GRID_CLASS); 214 + grid.style.transform = ""; 215 + dots.forEach(clearDot); 216 + styleEl.remove(); 217 + }; 218 + }; 219 + 220 + let cleanup = null; 221 + let current = null; 222 + const init = () => { 223 + const grid = document.querySelector("[data-punchcard]"); 224 + if (grid === current) return; 225 + if (cleanup) cleanup(); 226 + current = grid; 227 + cleanup = grid ? start(grid) : null; 228 + }; 229 + // Guard against import before the DOM is parsed — without this, a script tag 230 + // missing `defer` finds no punchcard and plain page loads never fire htmx:load. 231 + if (document.readyState === "loading") { 232 + document.addEventListener("DOMContentLoaded", init, { once: true }); 233 + } else { 234 + init(); 235 + } 236 + document.addEventListener("htmx:load", init);
+201
appview/pages/profile-fx/luisstd.js
··· 1 + const HUE = 276; 2 + const ACCENT_L = 0.563; 3 + const ACCENT_C = 0.219; 4 + const PALE_L = 0.86; 5 + const PALE_C = 0.035; 6 + const GLOW_LIFT = 0.07; 7 + const GLOW_REACH = 0.85; 8 + 9 + const LEVELS = 5; 10 + const GLOW_STEPS = 64; 11 + const LEVEL_CLASSES = [ 12 + "bg-green-200", 13 + "bg-green-300", 14 + "bg-green-400", 15 + "bg-green-500", 16 + ]; 17 + 18 + const BREATH_SCALE = 0.22; 19 + const BREATH_GLOW = 0.5; 20 + const GRAVITY_RADIUS = 130; 21 + const GRAVITY_PULL = 10; 22 + const ATTACK = 0.5; 23 + const RELEASE = 0.07; 24 + 25 + const WIDE = "(min-width: 768px)"; 26 + const REDUCE = "(prefers-reduced-motion: reduce)"; 27 + 28 + const COLORS = Array.from({ length: LEVELS }, (_, lv) => { 29 + const intensity = lv / (LEVELS - 1); 30 + return Array.from({ length: GLOW_STEPS }, (_, gs) => { 31 + const glow = gs / (GLOW_STEPS - 1); 32 + const e = intensity + (1 - intensity) * glow * GLOW_REACH; 33 + const L = PALE_L + (ACCENT_L - PALE_L) * e + GLOW_LIFT * glow; 34 + const C = PALE_C + (ACCENT_C - PALE_C) * e; 35 + return `oklch(${L.toFixed(3)} ${C.toFixed(3)} ${HUE})`; 36 + }); 37 + }); 38 + 39 + const attach = (grid) => { 40 + const dots = Array.from(grid.children, (c) => c.firstElementChild).filter( 41 + Boolean, 42 + ); 43 + if (!dots.length) return () => {}; 44 + 45 + const levels = new Uint8Array(dots.length); 46 + const hollow = dots.map((dot, i) => { 47 + levels[i] = LEVEL_CLASSES.findIndex((c) => dot.classList.contains(c)) + 1; 48 + return dot.classList.contains("border"); 49 + }); 50 + 51 + dots.forEach((dot) => { 52 + dot.style.transition = "none"; 53 + }); 54 + 55 + if (matchMedia(REDUCE).matches) { 56 + dots.forEach((dot, i) => { 57 + if (!hollow[i]) dot.style.backgroundColor = COLORS[levels[i]][0]; 58 + }); 59 + return () => {}; 60 + } 61 + 62 + const ac = new AbortController(); 63 + const opts = { signal: ac.signal }; 64 + const passive = { passive: true, signal: ac.signal }; 65 + const wide = matchMedia(WIDE); 66 + 67 + const grav = new Float32Array(dots.length); 68 + const lastGlow = new Int16Array(dots.length).fill(-1); 69 + const lastTransform = new Array(dots.length).fill(""); 70 + const offsets = new Float32Array(dots.length * 2); 71 + 72 + let cols = wide.matches ? 14 : 28; 73 + let rect = grid.getBoundingClientRect(); 74 + let mouseX = -9999; 75 + let mouseY = -9999; 76 + let raf = 0; 77 + let running = true; 78 + 79 + dots.forEach((dot) => { 80 + dot.style.willChange = "transform, background-color"; 81 + }); 82 + 83 + const measure = () => { 84 + cols = wide.matches ? 14 : 28; 85 + rect = grid.getBoundingClientRect(); 86 + dots.forEach((dot, i) => { 87 + const r = dot.parentElement.getBoundingClientRect(); 88 + offsets[i * 2] = r.left + r.width / 2 - rect.left; 89 + offsets[i * 2 + 1] = r.top + r.height / 2 - rect.top; 90 + }); 91 + }; 92 + 93 + const track = () => { 94 + rect = grid.getBoundingClientRect(); 95 + }; 96 + 97 + measure(); 98 + 99 + wide.addEventListener("change", measure, opts); 100 + window.addEventListener("resize", measure, opts); 101 + window.addEventListener("scroll", track, passive); 102 + window.addEventListener( 103 + "pointermove", 104 + (e) => { 105 + mouseX = e.clientX; 106 + mouseY = e.clientY; 107 + }, 108 + passive, 109 + ); 110 + document.addEventListener( 111 + "pointerleave", 112 + () => { 113 + mouseX = -9999; 114 + mouseY = -9999; 115 + }, 116 + opts, 117 + ); 118 + 119 + const frame = (now) => { 120 + const t = now / 1000; 121 + 122 + for (let i = 0; i < dots.length; i++) { 123 + const col = i % cols; 124 + const row = (i / cols) | 0; 125 + 126 + const w1 = Math.sin(col * 0.38 + row * 0.3 + t * 0.5); 127 + const w2 = Math.sin(col * 0.18 - row * 0.45 + t * 0.32 + 2.1); 128 + const breath = (w1 + w2) / 4 + 0.5; 129 + 130 + const dx = mouseX - (rect.left + offsets[i * 2]); 131 + const dy = mouseY - (rect.top + offsets[i * 2 + 1]); 132 + const dist = Math.hypot(dx, dy); 133 + 134 + let target = Math.max(0, 1 - dist / GRAVITY_RADIUS); 135 + target = target * target * (3 - 2 * target); 136 + grav[i] += (target - grav[i]) * (target > grav[i] ? ATTACK : RELEASE); 137 + const g = grav[i]; 138 + 139 + const pull = g * GRAVITY_PULL; 140 + const px = dist > 0 ? (dx / dist) * pull : 0; 141 + const py = dist > 0 ? (dy / dist) * pull : 0; 142 + 143 + const scale = 1 + breath * BREATH_SCALE + g * 0.55; 144 + const qx = Math.round(px * 4) / 4; 145 + const qy = Math.round(py * 4) / 4; 146 + const qs = Math.round(scale * 250) / 250; 147 + 148 + const tf = `translate(${qx}px,${qy}px) scale(${qs})`; 149 + if (tf !== lastTransform[i]) { 150 + dots[i].style.transform = tf; 151 + lastTransform[i] = tf; 152 + } 153 + 154 + if (hollow[i]) continue; 155 + 156 + const glow = Math.min(1, breath * BREATH_GLOW + g * 0.65); 157 + const gs = Math.min(GLOW_STEPS - 1, (glow * GLOW_STEPS) | 0); 158 + if (gs !== lastGlow[i]) { 159 + dots[i].style.backgroundColor = COLORS[levels[i]][gs]; 160 + lastGlow[i] = gs; 161 + } 162 + } 163 + 164 + raf = requestAnimationFrame(frame); 165 + }; 166 + 167 + const io = new IntersectionObserver((entries) => { 168 + const visible = entries.some((e) => e.isIntersecting); 169 + if (visible === running) return; 170 + running = visible; 171 + if (running) { 172 + measure(); 173 + raf = requestAnimationFrame(frame); 174 + } else { 175 + cancelAnimationFrame(raf); 176 + } 177 + }); 178 + io.observe(grid); 179 + 180 + raf = requestAnimationFrame(frame); 181 + 182 + return () => { 183 + cancelAnimationFrame(raf); 184 + io.disconnect(); 185 + ac.abort(); 186 + }; 187 + }; 188 + 189 + let teardown = null; 190 + let current = null; 191 + 192 + const boot = () => { 193 + const grid = document.querySelector("[data-punchcard]"); 194 + if (grid === current) return; 195 + if (teardown) teardown(); 196 + current = grid; 197 + teardown = grid ? attach(grid) : null; 198 + }; 199 + 200 + boot(); 201 + document.addEventListener("htmx:load", boot);
+349
appview/pages/profile-fx/matthewlipski.tngl.sh.js
··· 1 + // Draggable 3D punchcard, rendered on a canvas. We measure the real commit 2 + // dots once (position, size, colour, and an activity-based depth), hide the 3 + // original grid, and redraw the dots ourselves as a tilted 3D plane you can 4 + // grab and spin. Doing the projection in JS + canvas keeps 365 dots cheap where 5 + // per-dot CSS transforms did not. Self-contained, vanilla ES module. 6 + 7 + const REDUCED = matchMedia("(prefers-reduced-motion: reduce)"); 8 + const DEG = Math.PI / 180; 9 + 10 + const PERSPECTIVE = 1e6; // effectively infinite: near-orthographic, so the grid 11 + // looks identical to the flat default until you rotate 12 + const DEPTH = 46; // px — pillar height for the most active day 13 + const SHAFT_SHADE = 0.72; // shaft/base sit a touch darker than the lit top cap 14 + const HUE_SHIFT = 5; // max degrees the hue drifts (toward yellow/blue) at full yaw 15 + const GROUND_RING = 0.18; // outline thickness of ground dots, as a fraction of radius 16 + const GHOST_COLOR = "rgba(128,128,128,0.5)"; // fallback outline for empty days 17 + const DRAG_SENS = 0.45; // degrees of rotation per pixel dragged 18 + const REST_TILT_X = 0; // resting pitch the grid springs back to 19 + const REST_TILT_Y = 0; // resting yaw the grid springs back to 20 + const STIFFNESS = 0.08; // spring pull back toward the resting tilt 21 + const DAMPING = 0.82; // spring velocity decay per frame 22 + const MAX_ANGLE = 88; // clamp every rotation axis to just under 90° 23 + 24 + const clampAngle = (v) => Math.max(-MAX_ANGLE, Math.min(MAX_ANGLE, v)); 25 + 26 + let grid = null; 27 + let canvas = null; 28 + let ctx = null; 29 + let dpr = 1; 30 + let cx = 0; // grid centre, in CSS px 31 + let cy = 0; 32 + let dots = []; // { x, y, h, r, rgb } — x/y centred on the grid centre 33 + let ghosts = []; // { x, y, r, color } — empty days, drawn as flat ground outlines 34 + let rotX = REST_TILT_X; 35 + let rotY = REST_TILT_Y; 36 + let velX = 0; 37 + let velY = 0; 38 + let dragging = false; 39 + let lastX = 0; 40 + let lastY = 0; 41 + let raf = 0; 42 + 43 + // Parse a dot's fill to [r,g,b]; null if fully transparent (empty day). 44 + function parseColor(str) { 45 + const m = str.match(/[\d.]+/g); 46 + if (!m) return null; 47 + const [r, g, b, a = 1] = m.map(Number); 48 + return a === 0 ? null : [r, g, b]; 49 + } 50 + 51 + // Chroma is a theme-agnostic proxy for activity: grey empty days sit flat, 52 + // saturated active days pop toward the viewer. 53 + function activity([r, g, b]) { 54 + return (Math.max(r, g, b) - Math.min(r, g, b)) / 255; 55 + } 56 + 57 + // Rotate an [r,g,b]'s hue by `deg` degrees, preserving saturation/lightness. 58 + // deg 0 returns the colour untouched, and greys (no hue) are left as-is. 59 + function shiftHue([r, g, b], deg) { 60 + if (!deg) return [r, g, b]; 61 + const rn = r / 255, gn = g / 255, bn = b / 255; 62 + const max = Math.max(rn, gn, bn), min = Math.min(rn, gn, bn); 63 + const c = max - min; 64 + if (c === 0) return [r, g, b]; 65 + const l = (max + min) / 2; 66 + const s = l > 0.5 ? c / (2 - max - min) : c / (max + min); 67 + let h; 68 + if (max === rn) h = (gn - bn) / c + (gn < bn ? 6 : 0); 69 + else if (max === gn) h = (bn - rn) / c + 2; 70 + else h = (rn - gn) / c + 4; 71 + h = (((h * 60 + deg) % 360) + 360) % 360 / 360; 72 + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; 73 + const p = 2 * l - q; 74 + const chan = (t) => { 75 + if (t < 0) t += 1; 76 + if (t > 1) t -= 1; 77 + if (t < 1 / 6) return p + (q - p) * 6 * t; 78 + if (t < 1 / 2) return q; 79 + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; 80 + return p; 81 + }; 82 + return [ 83 + Math.round(chan(h + 1 / 3) * 255), 84 + Math.round(chan(h) * 255), 85 + Math.round(chan(h - 1 / 3) * 255), 86 + ]; 87 + } 88 + 89 + // Snapshot dot geometry/colour off the live DOM and size the canvas to match. 90 + function measure() { 91 + const gr = grid.getBoundingClientRect(); 92 + cx = gr.width / 2; 93 + cy = gr.height / 2; 94 + 95 + ghosts = []; 96 + dots = Array.from(grid.children) 97 + .map((cell) => { 98 + const el = cell.firstElementChild; 99 + if (!el || el === canvas) return null; 100 + const cs = getComputedStyle(el); 101 + const rect = el.getBoundingClientRect(); 102 + const x = rect.left - gr.left + rect.width / 2 - cx; 103 + const y = rect.top - gr.top + rect.height / 2 - cy; 104 + const rad = Math.min(rect.width, rect.height) / 2; 105 + const rgb = parseColor(cs.backgroundColor); 106 + if (!rgb) { 107 + // Empty day: keep it as a flat outline on the ground plane, matching 108 + // the real dot's border colour and thickness. 109 + const bc = parseColor(cs.borderTopColor); 110 + const bw = parseFloat(cs.borderTopWidth) || 0; 111 + ghosts.push({ 112 + x, y, r: rad, 113 + color: bc ? `rgb(${bc[0]},${bc[1]},${bc[2]})` : GHOST_COLOR, 114 + ring: bw > 0 ? Math.min(bw / rad, 0.9) : GROUND_RING, 115 + }); 116 + return null; 117 + } 118 + return { x, y, h: activity(rgb) * DEPTH, r: rad, rgb }; 119 + }) 120 + .filter(Boolean); 121 + 122 + dpr = window.devicePixelRatio || 1; 123 + canvas.width = Math.round(gr.width * dpr); 124 + canvas.height = Math.round(gr.height * dpr); 125 + canvas.style.width = gr.width + "px"; 126 + canvas.style.height = gr.height + "px"; 127 + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); 128 + } 129 + 130 + // Rotate a plane point (x, y, z) into camera space, then perspective-project 131 + // it to screen. Returns null if it lands behind the camera. 132 + function project(x, y, z, sinX, cosX, sinY, cosY) { 133 + // rotateY then rotateX, matching CSS `rotateX(..) rotateY(..)`. 134 + const x1 = x * cosY + z * sinY; 135 + const z1 = -x * sinY + z * cosY; 136 + const y2 = y * cosX - z1 * sinX; 137 + const z2 = y * sinX + z1 * cosX; 138 + const denom = PERSPECTIVE - z2; 139 + if (denom <= 1) return null; // at or behind the camera 140 + const s = PERSPECTIVE / denom; 141 + return { sx: cx + x1 * s, sy: cy + y2 * s, s, z: z2 }; 142 + } 143 + 144 + // Draw a dot's disc at height z as a filled ellipse — the true perspective 145 + // projection of a circle lying flat in the grid plane, so it foreshortens with 146 + // tilt instead of always facing the camera. `c` is the already-projected centre. 147 + function drawCap(c, d, z, color, trig) { 148 + const u = project(d.x + d.r, d.y, z, trig[0], trig[1], trig[2], trig[3]); 149 + const v = project(d.x, d.y + d.r, z, trig[0], trig[1], trig[2], trig[3]); 150 + if (!u || !v) return; 151 + ctx.fillStyle = color; 152 + ctx.save(); 153 + // Map the unit circle through the two projected radius vectors. 154 + ctx.transform(u.sx - c.sx, u.sy - c.sy, v.sx - c.sx, v.sy - c.sy, c.sx, c.sy); 155 + ctx.beginPath(); 156 + ctx.arc(0, 0, 1, 0, Math.PI * 2); 157 + ctx.fill(); 158 + ctx.restore(); 159 + } 160 + 161 + // Draw a height-0 dot as a foreshortened ring lying flat on the ground plane. 162 + // `c` is the already-projected centre. 163 + function drawRing(c, x, y, r, color, ring, trig) { 164 + const u = project(x + r, y, 0, trig[0], trig[1], trig[2], trig[3]); 165 + const v = project(x, y + r, 0, trig[0], trig[1], trig[2], trig[3]); 166 + if (!u || !v) return; 167 + ctx.fillStyle = color; 168 + ctx.save(); 169 + ctx.transform(u.sx - c.sx, u.sy - c.sy, v.sx - c.sx, v.sy - c.sy, c.sx, c.sy); 170 + ctx.beginPath(); 171 + ctx.arc(0, 0, 1, 0, Math.PI * 2); // outer edge 172 + ctx.arc(0, 0, 1 - ring, 0, Math.PI * 2, true); // inner edge (hole) 173 + ctx.fill(); 174 + ctx.restore(); 175 + } 176 + 177 + // Project each pillar (base at z=0, top at z=h) and draw back-to-front. 178 + function render() { 179 + if (!ctx) return; 180 + const rx = rotX * DEG; 181 + const ry = rotY * DEG; 182 + const trig = [Math.sin(rx), Math.cos(rx), Math.sin(ry), Math.cos(ry)]; 183 + 184 + const drawn = []; 185 + for (let i = 0; i < dots.length; i++) { 186 + const d = dots[i]; 187 + const b = project(d.x, d.y, 0, trig[0], trig[1], trig[2], trig[3]); 188 + const t = project(d.x, d.y, d.h, trig[0], trig[1], trig[2], trig[3]); 189 + if (!b || !t) continue; 190 + drawn.push({ d, b, t, z: b.z }); // sort by base depth: far rows drawn first 191 + } 192 + for (let i = 0; i < ghosts.length; i++) { 193 + const g = ghosts[i]; 194 + const c = project(g.x, g.y, 0, trig[0], trig[1], trig[2], trig[3]); 195 + if (!c) continue; 196 + drawn.push({ g, c, z: c.z }); 197 + } 198 + drawn.sort((a, b) => a.z - b.z); 199 + 200 + // Hue drifts toward yellow/blue with yaw; exactly zero (green) when centred. 201 + const hueDelta = (rotY / MAX_ANGLE) * HUE_SHIFT; 202 + 203 + ctx.clearRect(0, 0, cx * 2, cy * 2); 204 + for (let i = 0; i < drawn.length; i++) { 205 + if (drawn[i].g) { 206 + const { g, c } = drawn[i]; 207 + drawRing(c, g.x, g.y, g.r, g.color, g.ring, trig); 208 + continue; 209 + } 210 + const { d, b, t } = drawn[i]; 211 + const [cr, cg, cb] = shiftHue(d.rgb, hueDelta); 212 + const topColor = `rgb(${cr},${cg},${cb})`; 213 + const shaftColor = `rgb(${Math.round(cr * SHAFT_SHADE)},${Math.round(cg * SHAFT_SHADE)},${Math.round(cb * SHAFT_SHADE)})`; 214 + // Base cap first (farthest), in the pillar-body colour. 215 + drawCap(b, d, 0, shaftColor, trig); 216 + // Shaft: a tapered quad from the base circle to the top circle. 217 + const ax = t.sx - b.sx, ay = t.sy - b.sy; 218 + const len = Math.hypot(ax, ay) || 1; 219 + const nx = -ay / len, ny = ax / len; // screen-space perpendicular 220 + const rB = d.r * b.s, rT = d.r * t.s; 221 + ctx.fillStyle = shaftColor; 222 + ctx.beginPath(); 223 + ctx.moveTo(b.sx + nx * rB, b.sy + ny * rB); 224 + ctx.lineTo(t.sx + nx * rT, t.sy + ny * rT); 225 + ctx.lineTo(t.sx - nx * rT, t.sy - ny * rT); 226 + ctx.lineTo(b.sx - nx * rB, b.sy - ny * rB); 227 + ctx.closePath(); 228 + ctx.fill(); 229 + // Top cap last (nearest), as the lit colour. 230 + drawCap(t, d, d.h, topColor, trig); 231 + } 232 + } 233 + 234 + // Damped spring back to the resting tilt, seeded with the drag's leftover 235 + // velocity so it eases home with a little overshoot. 236 + function recenter() { 237 + raf = 0; 238 + if (dragging) return; 239 + velX = (velX + (REST_TILT_X - rotX) * STIFFNESS) * DAMPING; 240 + velY = (velY + (REST_TILT_Y - rotY) * STIFFNESS) * DAMPING; 241 + rotX += velX; 242 + rotY += velY; 243 + render(); 244 + const settled = 245 + Math.abs(velX) < 0.02 && Math.abs(velY) < 0.02 && 246 + Math.abs(rotX - REST_TILT_X) < 0.05 && Math.abs(rotY - REST_TILT_Y) < 0.05; 247 + if (settled) { 248 + rotX = REST_TILT_X; 249 + rotY = REST_TILT_Y; 250 + render(); 251 + } else { 252 + raf = requestAnimationFrame(recenter); 253 + } 254 + } 255 + 256 + function onDown(e) { 257 + dragging = true; 258 + velX = velY = 0; 259 + if (raf) cancelAnimationFrame(raf), (raf = 0); 260 + lastX = e.clientX; 261 + lastY = e.clientY; 262 + canvas.style.cursor = "grabbing"; 263 + if (e.pointerId != null) canvas.setPointerCapture(e.pointerId); 264 + e.preventDefault(); 265 + } 266 + 267 + function onMove(e) { 268 + if (!dragging) return; 269 + const dx = e.clientX - lastX; 270 + const dy = e.clientY - lastY; 271 + lastX = e.clientX; 272 + lastY = e.clientY; 273 + rotY = clampAngle(rotY + dx * DRAG_SENS); 274 + rotX = clampAngle(rotX - dy * DRAG_SENS); 275 + velX = -dy * DRAG_SENS; // remember last motion to fling on release 276 + velY = dx * DRAG_SENS; 277 + render(); 278 + } 279 + 280 + function onUp(e) { 281 + if (!dragging) return; 282 + dragging = false; 283 + canvas.style.cursor = "grab"; 284 + if (e && e.pointerId != null && canvas.hasPointerCapture(e.pointerId)) { 285 + canvas.releasePointerCapture(e.pointerId); 286 + } 287 + // Spring back to the resting tilt, carrying the leftover drag velocity. 288 + raf = requestAnimationFrame(recenter); 289 + } 290 + 291 + function mount() { 292 + const next = document.querySelector("[data-punchcard]"); 293 + if (next === grid && grid) return; // already wired to this element 294 + if (raf) cancelAnimationFrame(raf), (raf = 0); 295 + grid = next; 296 + if (!grid) return; 297 + 298 + if (getComputedStyle(grid).position === "static") { 299 + grid.style.position = "relative"; 300 + } 301 + 302 + if (!canvas || canvas.parentElement !== grid) { 303 + canvas = document.createElement("canvas"); 304 + canvas.dataset.punchcardFx = ""; 305 + Object.assign(canvas.style, { 306 + position: "absolute", 307 + left: "0", 308 + top: "0", 309 + touchAction: "none", 310 + }); 311 + ctx = canvas.getContext("2d"); 312 + grid.appendChild(canvas); 313 + } 314 + 315 + measure(); 316 + 317 + // Hide the originals but keep them occupying space, so the grid keeps its 318 + // size and our canvas has the same footprint. 319 + for (const cell of grid.children) { 320 + if (cell !== canvas) cell.style.visibility = "hidden"; 321 + } 322 + 323 + rotX = REST_TILT_X; 324 + rotY = REST_TILT_Y; 325 + velX = velY = 0; 326 + render(); 327 + 328 + // Reduced motion: render the static tilted relief but wire no dragging. 329 + if (REDUCED.matches) { 330 + canvas.style.cursor = ""; 331 + return; 332 + } 333 + canvas.style.cursor = "grab"; 334 + canvas.addEventListener("pointerdown", onDown); 335 + canvas.addEventListener("pointermove", onMove); 336 + canvas.addEventListener("pointerup", onUp); 337 + canvas.addEventListener("pointercancel", onUp); 338 + canvas.addEventListener("lostpointercapture", onUp); 339 + } 340 + 341 + function remeasure() { 342 + if (!grid || !canvas) return; 343 + measure(); 344 + render(); 345 + } 346 + 347 + window.addEventListener("resize", remeasure); 348 + document.addEventListener("htmx:load", mount); 349 + mount();
+326
appview/pages/profile-fx/oppili.js
··· 1 + // Brickblaster — punchcard edition 2 + // Vanilla ES module. No imports, no build step. 3 + 4 + const HS_KEY = 'brickblaster-hs'; 5 + const WQ = '(min-width: 768px)'; 6 + const BALL_D = 10; 7 + const BALL_R = BALL_D / 2; 8 + const PAD_H = 6; 9 + const PAD_GAP = 10; 10 + const PAD_CELLS = 5; 11 + 12 + const lsGet = k => { try { return localStorage.getItem(k); } catch (e) { return null; } }; 13 + const lsSet = (k, v) => { try { localStorage.setItem(k, v); } catch (e) {} }; 14 + 15 + // True if the dot has a contribution colour (not gray). 16 + // Uses HSL saturation: gray dots have s < 0.25, green contribution dots are much higher. 17 + const isGreen = (el) => { 18 + const c = getComputedStyle(el).backgroundColor; 19 + const m = c.match(/[\d.]+/g); 20 + if (!m || m.length < 3) return false; 21 + const r = +m[0] / 255, g = +m[1] / 255, b = +m[2] / 255; 22 + const mx = Math.max(r, g, b), mn = Math.min(r, g, b); 23 + if (mx === mn) return false; 24 + const l = (mx + mn) / 2; 25 + return (mx - mn) / (l > 0.5 ? 2 - mx - mn : mx + mn) > 0.25; 26 + }; 27 + 28 + const run = (N) => { 29 + if (matchMedia('(prefers-reduced-motion: reduce)').matches) return () => {}; 30 + 31 + const ac = new AbortController(); 32 + const mq = matchMedia(WQ); 33 + const sig = { signal: ac.signal }; 34 + 35 + const getCols = () => mq.matches ? 14 : 28; 36 + const resetDot = el => { 37 + el.style.backgroundColor = ''; 38 + el.style.transform = ''; 39 + el.style.opacity = ''; 40 + }; 41 + 42 + const readGrid = () => { 43 + const dots = Array.from(N.children, w => w.firstElementChild).filter(Boolean); 44 + const cols = getCols(); 45 + return { dots, cols, rows: Math.ceil(dots.length / cols) }; 46 + }; 47 + 48 + let G = readGrid(); 49 + 50 + G.dots.forEach(el => { 51 + el.style.transition = 'none'; 52 + el.style.transformOrigin = 'center'; 53 + el.style.willChange = 'transform, opacity, background-color'; 54 + }); 55 + 56 + const origPos = N.style.position; 57 + N.style.position = 'relative'; 58 + N.style.cursor = 'default'; 59 + 60 + // Ball element — inside N, can visually overflow below 61 + const ballEl = document.createElement('div'); 62 + Object.assign(ballEl.style, { 63 + position: 'absolute', 64 + width: BALL_D + 'px', 65 + height: BALL_D + 'px', 66 + borderRadius: '50%', 67 + backgroundColor: '#3b82f6', 68 + boxShadow: '0 0 6px #3b82f680', 69 + pointerEvents: 'none', 70 + zIndex: '10', 71 + display: 'none', 72 + }); 73 + N.appendChild(ballEl); 74 + 75 + // Paddle — block element below N 76 + const padWrap = document.createElement('div'); 77 + padWrap.style.cssText = `position:relative; height:${PAD_H}px; margin-top:${PAD_GAP}px;`; 78 + N.parentNode.insertBefore(padWrap, N.nextSibling); 79 + 80 + const padEl = document.createElement('div'); 81 + padEl.style.cssText = `position:absolute; height:${PAD_H}px; border-radius:3px; background:#3b82f6; box-shadow:0 0 6px #3b82f680; top:0;`; 82 + padWrap.appendChild(padEl); 83 + 84 + // HUD 85 + const hud = document.createElement('div'); 86 + Object.assign(hud.style, { 87 + fontSize: '11px', 88 + fontFamily: 'ui-monospace, monospace', 89 + color: '#6b7280', 90 + marginBottom: '6px', 91 + userSelect: 'none', 92 + letterSpacing: '0.04em', 93 + }); 94 + N.parentNode.insertBefore(hud, N); 95 + 96 + // State 97 + let phase = 'idle'; 98 + let score = 0; 99 + let hs = +(lsGet(HS_KEY) || 0); 100 + let brickMap = []; // which dots are green/collidable 101 + let alive = []; // which bricks haven't been destroyed yet 102 + let bx = 0, by = 0, bvx = 0, bvy = 0; 103 + let padX = 0; 104 + let padXReady = false; 105 + 106 + // Arrow keys 107 + const keys = { left: false, right: false }; 108 + window.addEventListener('keydown', e => { 109 + if (e.key === 'ArrowLeft') { keys.left = true; if (phase === 'playing') e.preventDefault(); } 110 + if (e.key === 'ArrowRight') { keys.right = true; if (phase === 'playing') e.preventDefault(); } 111 + }, sig); 112 + window.addEventListener('keyup', e => { 113 + if (e.key === 'ArrowLeft') keys.left = false; 114 + if (e.key === 'ArrowRight') keys.right = false; 115 + }, sig); 116 + 117 + const getMetrics = () => { 118 + const gridW = N.offsetWidth; 119 + const gridH = N.offsetHeight; 120 + const cellW = gridW / G.cols; 121 + const cellH = gridH / G.rows; 122 + const padW = cellW * PAD_CELLS; 123 + const paddleY = gridH + PAD_GAP + PAD_H / 2; 124 + const padSpeed = Math.max(100, gridW / 0.6); 125 + const ballSpeed = Math.max(80, gridH / 1.2); 126 + return { gridW, gridH, cellW, cellH, padW, paddleY, padSpeed, ballSpeed }; 127 + }; 128 + 129 + const showHud = () => { 130 + if (phase === 'idle') hud.textContent = `BRICKBLASTER | BEST: ${hs} | click to start`; 131 + else if (phase === 'playing') hud.textContent = `SCORE: ${score} | BEST: ${hs}`; 132 + else hud.textContent = `GAME OVER | SCORE: ${score} | BEST: ${hs} | click to replay`; 133 + }; 134 + 135 + const startGame = () => { 136 + score = 0; 137 + brickMap = G.dots.map(el => isGreen(el)); // snapshot which dots are green 138 + alive = brickMap.slice(); 139 + 140 + const m = getMetrics(); 141 + if (!padXReady) { padX = m.gridW / 2; padXReady = true; } 142 + 143 + const a = Math.PI * (0.3 + Math.random() * 0.4); // 54–126°, always upward 144 + bx = padX; 145 + by = m.gridH * 0.7; 146 + bvx = Math.cos(a) * m.ballSpeed; 147 + bvy = -Math.sin(a) * m.ballSpeed; 148 + 149 + phase = 'playing'; 150 + ballEl.style.display = 'block'; 151 + showHud(); 152 + }; 153 + 154 + const endGame = () => { 155 + if (score > hs) { hs = score; lsSet(HS_KEY, hs); } 156 + phase = 'gameover'; 157 + ballEl.style.display = 'none'; 158 + showHud(); 159 + }; 160 + 161 + const update = (dt, m) => { 162 + if (phase !== 'playing') return; 163 + 164 + const hw = m.padW / 2; 165 + if (keys.left) padX = Math.max(hw, padX - m.padSpeed * dt); 166 + if (keys.right) padX = Math.min(m.gridW - hw, padX + m.padSpeed * dt); 167 + 168 + bx += bvx * dt; 169 + by += bvy * dt; 170 + 171 + // Wall bounces 172 + if (bx < BALL_R) { bx = BALL_R; bvx = Math.abs(bvx); } 173 + if (bx > m.gridW - BALL_R) { bx = m.gridW - BALL_R; bvx = -Math.abs(bvx); } 174 + if (by < BALL_R) { by = BALL_R; bvy = Math.abs(bvy); } 175 + 176 + // Ball falls past paddle → game over 177 + if (by > m.paddleY + PAD_H + BALL_R) { endGame(); return; } 178 + 179 + // Paddle collision 180 + const padTop = m.paddleY - PAD_H / 2 - BALL_R; 181 + if (bvy > 0 && by >= padTop && by <= m.paddleY + PAD_H) { 182 + if (Math.abs(bx - padX) <= hw + BALL_R) { 183 + by = padTop; 184 + const offset = (bx - padX) / hw; 185 + const speed = Math.hypot(bvx, bvy); 186 + bvx = offset * speed * 0.8; 187 + const sp = Math.hypot(bvx, bvy) || speed; 188 + bvx = (bvx / sp) * speed; 189 + bvy = -Math.abs((bvy / sp) * speed); 190 + } 191 + } 192 + 193 + // Brick collision — only green (brickMap) dots that are still alive 194 + const HIT_R = BALL_R + Math.min(m.cellW, m.cellH) * 0.4; 195 + const c0 = Math.round(bx / m.cellW - 0.5); 196 + const r0 = Math.round(by / m.cellH - 0.5); 197 + let hitDone = false; 198 + for (let dr = -1; dr <= 1 && !hitDone; dr++) { 199 + for (let dc = -1; dc <= 1 && !hitDone; dc++) { 200 + const c = c0 + dc, r = r0 + dr; 201 + if (c < 0 || c >= G.cols || r < 0 || r >= G.rows) continue; 202 + const idx = r * G.cols + c; 203 + if (!alive[idx]) continue; 204 + const dotX = (c + 0.5) * m.cellW; 205 + const dotY = (r + 0.5) * m.cellH; 206 + const dist = Math.hypot(bx - dotX, by - dotY); 207 + if (dist >= HIT_R) continue; 208 + 209 + alive[idx] = false; 210 + score += 1; 211 + if (score > hs) { hs = score; lsSet(HS_KEY, hs); } 212 + showHud(); 213 + 214 + const nx = (bx - dotX) / (dist || 1); 215 + const ny = (by - dotY) / (dist || 1); 216 + const dp = bvx * nx + bvy * ny; 217 + if (dp < 0) { bvx -= 2 * dp * nx; bvy -= 2 * dp * ny; } 218 + hitDone = true; 219 + } 220 + } 221 + 222 + if (!alive.some(a => a)) endGame(); 223 + }; 224 + 225 + const render = (m) => { 226 + if (!padXReady && m.gridW > 0) { padX = m.gridW / 2; padXReady = true; } 227 + 228 + const hw = m.padW / 2; 229 + const cx = Math.max(hw, Math.min(m.gridW - hw, padX)); 230 + 231 + padEl.style.left = (cx - hw) + 'px'; 232 + padEl.style.width = m.padW + 'px'; 233 + 234 + if (phase === 'playing') { 235 + ballEl.style.left = (bx - BALL_R) + 'px'; 236 + ballEl.style.top = (by - BALL_R) + 'px'; 237 + } 238 + 239 + G.dots.forEach((el, i) => { 240 + // Idle or non-brick gray dot: restore natural appearance 241 + if (phase === 'idle' || !brickMap[i]) { resetDot(el); return; } 242 + 243 + if (alive[i]) { 244 + // Alive green brick: keep natural color, scale up so it reads as a target 245 + el.style.backgroundColor = ''; 246 + el.style.transform = 'scale(1.5)'; 247 + el.style.opacity = ''; 248 + } else { 249 + // Destroyed: shrink and turn gray 250 + el.style.backgroundColor = '#6b7280'; 251 + el.style.transform = 'scale(0.6)'; 252 + el.style.opacity = ''; 253 + } 254 + }); 255 + }; 256 + 257 + N.addEventListener('pointerdown', e => { 258 + if (e.button !== 0) return; 259 + if (phase !== 'playing') startGame(); 260 + }, sig); 261 + 262 + let raf = 0, lastT = 0, visible = true; 263 + 264 + const frame = t => { 265 + const dt = lastT ? Math.min((t - lastT) / 1000, 0.05) : 0; 266 + lastT = t; 267 + const m = getMetrics(); 268 + update(dt, m); 269 + render(m); 270 + if (visible) raf = requestAnimationFrame(frame); 271 + }; 272 + 273 + const io = new IntersectionObserver(entries => { 274 + const v = entries.some(e => e.isIntersecting); 275 + if (v === visible) return; 276 + visible = v; 277 + if (visible) { lastT = 0; raf = requestAnimationFrame(frame); } 278 + else cancelAnimationFrame(raf); 279 + }); 280 + io.observe(N); 281 + 282 + raf = requestAnimationFrame(frame); 283 + 284 + mq.addEventListener('change', () => { 285 + G = readGrid(); 286 + G.dots.forEach(el => { 287 + el.style.transition = 'none'; 288 + el.style.transformOrigin = 'center'; 289 + el.style.willChange = 'transform, opacity, background-color'; 290 + }); 291 + padXReady = false; 292 + phase = 'idle'; 293 + ballEl.style.display = 'none'; 294 + G.dots.forEach(resetDot); 295 + showHud(); 296 + }, sig); 297 + 298 + showHud(); 299 + 300 + return () => { 301 + cancelAnimationFrame(raf); 302 + io.disconnect(); 303 + ac.abort(); 304 + hud.remove(); 305 + ballEl.remove(); 306 + padWrap.remove(); 307 + N.style.position = origPos; 308 + N.style.cursor = ''; 309 + G.dots.forEach(resetDot); 310 + }; 311 + }; 312 + 313 + // Bootstrap — mirrors orrery.js pattern, survives htmx navigation 314 + let _cleanup = null; 315 + let _el = null; 316 + 317 + const _setup = () => { 318 + const el = document.querySelector('[data-punchcard]'); 319 + if (el === _el) return; 320 + if (_cleanup) _cleanup(); 321 + _el = el; 322 + _cleanup = el ? run(el) : null; 323 + }; 324 + 325 + _setup(); 326 + document.addEventListener('htmx:load', _setup);
+481
appview/pages/profile-fx/punchcard-td.js
··· 1 + // Punchcard Tower Defense: the punchcard itself becomes the battlefield. 2 + // Click "play" to overlay the game on your commit grid; the dots march the 3 + // track and their punchcard color is their strength. Build towers on the dark 4 + // tiles to stop them. Hit "exit" to return to the normal punchcard. 5 + 6 + // Tangled swaps profiles in via htmx, so the module only executes once. Re-bind 7 + // to whichever punchcard is on the page after each swap, tearing down the old 8 + // instance first so nothing (rAF loop, window listener, injected DOM) leaks. 9 + let tdTeardown = null; 10 + let tdGrid = null; 11 + function tdBoot() { 12 + const grid = document.querySelector("[data-punchcard]"); 13 + if (grid === tdGrid) return; 14 + if (tdTeardown) tdTeardown(); 15 + tdGrid = grid; 16 + tdTeardown = grid ? run(grid) : null; 17 + } 18 + tdBoot(); 19 + document.addEventListener("htmx:load", tdBoot); 20 + 21 + function run(grid) { 22 + grid.dataset.tdOn = "1"; 23 + const parent = grid.parentElement || document.body; 24 + const ac = new AbortController(); 25 + const sig = ac.signal; 26 + 27 + // ---- read commit data off the real punchcard dots ---- 28 + const parseCount = (dot) => { 29 + const m = dot && dot.title && dot.title.match(/(\d+)\s*commits?/i); 30 + return m ? parseInt(m[1], 10) : 0; 31 + }; 32 + const roster = []; 33 + Array.from(grid.children).forEach((cell) => { 34 + const dot = cell.firstElementChild; 35 + const count = parseCount(dot); 36 + if (count > 0) { 37 + const month = parseInt(dot.title.slice(5, 7), 10) - 1; 38 + roster.push({ dot, count, month, color: getComputedStyle(dot).backgroundColor }); 39 + } 40 + }); 41 + if (!roster.length) return; 42 + 43 + // ---- difficulty tunables (cranked up) ---- 44 + const START_GOLD = 120, SPAWN_GAP = 0.42, WAVE_GAP = 1.2; 45 + const RAD = 6; // every enemy is the same size; color shows strength 46 + const speedFor = (c) => Math.max(0.9, 1.9 - c * 0.05); // base tiles / second (brighter = slower) 47 + const waveSpeed = (w) => 1 + 0.11 * w; // ...and everything speeds up each wave 48 + const rewardFor = (c) => 1 + c; 49 + const hpBase = (c) => 12 + c * 9; // health scales with commits (color) 50 + const hpFor = (c, w) => Math.round(hpBase(c) * (1 + 0.14 * w)); // ...and ramps up hard each wave 51 + // peon & rapid deal damage; frost deals none but chills (slows) so your killers get more shots in 52 + const TOWERS = { 53 + peon: { cost: 50, range: 2.0, cd: 0.35, dmg: 9, body: "#38bdf8", proj: "#7dd3fc", slow: 0, slowT: 0 }, 54 + frost: { cost: 70, range: 2.4, cd: 0.50, dmg: 0, body: "#67e8f9", proj: "#a5f3fc", slow: 0.4, slowT: 1.4 }, 55 + rapid: { cost: 90, range: 1.7, cd: 0.16, dmg: 4, body: "#a78bfa", proj: "#c4b5fd", slow: 0, slowT: 0 }, 56 + }; 57 + const MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; 58 + 59 + // ---- waves (grouped by month) ---- 60 + const greenFor = (c) => (c >= 8 ? "#22c55e" : c >= 4 ? "#4ade80" : c >= 2 ? "#86efac" : "#bbf7d0"); 61 + const groupWaves = (list) => { 62 + const bm = Array.from({ length: 12 }, () => []); 63 + list.forEach((e) => bm[e.month].push(e)); 64 + return bm.map((l, m) => ({ m, list: l })).filter((w) => w.list.length); 65 + }; 66 + let waves = groupWaves(roster); 67 + let totalEnemies = roster.length; 68 + let boss = false; 69 + 70 + // The "final boss": a nod to Lewis (oyster.cafe) and his absurd commit count. We don't 71 + // fetch anything, just conjure a swarm sized off the number, ~1 dot per 100 commits. 72 + const BOSS_NAME = "Lewis"; 73 + const BOSS_COMMITS = 25241; 74 + const bossRoster = () => { 75 + const n = Math.round(BOSS_COMMITS / 100); // ~252 dots, each worth ~100 commits 76 + const list = []; 77 + for (let i = 0; i < n; i++) { 78 + const count = 8 + ((i * 7) % 8); // all maxed-out (brightest); every day is a grind for Lewis 79 + list.push({ count, month: i % 12, color: greenFor(count) }); 80 + } 81 + return list; 82 + }; 83 + 84 + // ---- board geometry (recomputed on enter, sized to the punchcard) ---- 85 + let COLS, ROWS, TILE, W, H, pts, segLen, cum, pathLen, blocked, buildable; 86 + const keyOf = (c, r) => c + "," + r; 87 + 88 + function serpentine(cols, rows) { 89 + const lanes = []; 90 + for (let r = 0; r < rows; r += 2) lanes.push(r); 91 + const wp = [[-1, lanes[0]]]; 92 + for (let i = 0; i < lanes.length; i++) { 93 + const ltr = i % 2 === 0, a = ltr ? 0 : cols - 1, b = ltr ? cols - 1 : 0; 94 + wp.push([a, lanes[i]], [b, lanes[i]]); 95 + if (i < lanes.length - 1) wp.push([b, lanes[i + 1]]); 96 + } 97 + const li = lanes.length - 1; 98 + wp.push([li % 2 === 0 ? cols : -1, lanes[li]]); 99 + return wp; 100 + } 101 + 102 + function layout() { 103 + const cw = Math.max(160, grid.clientWidth); 104 + const ch = Math.max(160, grid.clientHeight); 105 + COLS = 8; 106 + TILE = cw / COLS; 107 + ROWS = Math.max(7, Math.min(20, Math.floor(ch / TILE))); 108 + W = cw; H = ch; // exactly cover the punchcard, never taller than it 109 + const wp = serpentine(COLS, ROWS); 110 + pts = wp.map(([c, r]) => ({ x: c * TILE + TILE / 2, y: r * TILE + TILE / 2 })); 111 + segLen = []; cum = [0]; 112 + for (let i = 0; i < pts.length - 1; i++) { 113 + const L = Math.hypot(pts[i + 1].x - pts[i].x, pts[i + 1].y - pts[i].y); 114 + segLen.push(L); cum.push(cum[i] + L); 115 + } 116 + pathLen = cum[cum.length - 1]; 117 + blocked = new Set(); 118 + for (let i = 0; i < wp.length - 1; i++) { 119 + let [c, r] = wp[i]; const [c1, r1] = wp[i + 1]; 120 + const dc = Math.sign(c1 - c), dr = Math.sign(r1 - r); 121 + blocked.add(keyOf(c, r)); 122 + while (c !== c1 || r !== r1) { c += dc; r += dr; blocked.add(keyOf(c, r)); } 123 + } 124 + buildable = []; 125 + for (let r = 0; r < ROWS; r++) 126 + for (let c = 0; c < COLS; c++) 127 + if (!blocked.has(keyOf(c, r))) buildable.push([c, r]); 128 + } 129 + 130 + const pointAt = (d) => { 131 + if (d <= 0) return { x: pts[0].x, y: pts[0].y }; 132 + if (d >= pathLen) return { x: pts[pts.length - 1].x, y: pts[pts.length - 1].y }; 133 + let i = 0; 134 + while (i < segLen.length && cum[i + 1] < d) i++; 135 + const t = (d - cum[i]) / segLen[i]; 136 + return { x: pts[i].x + (pts[i + 1].x - pts[i].x) * t, y: pts[i].y + (pts[i + 1].y - pts[i].y) * t }; 137 + }; 138 + 139 + // ---- DOM ---- 140 + const el = (tag, style, text) => { 141 + const n = document.createElement(tag); 142 + if (style) n.style.cssText = style; 143 + if (text != null) n.textContent = text; 144 + return n; 145 + }; 146 + const btn = (label) => 147 + el("button", "font-family:ui-monospace,Menlo,monospace;font-size:11px;padding:5px 8px;border-radius:6px;border:1px solid rgba(120,120,135,0.4);background:rgba(2,6,23,0.6);color:#cbd5e1;cursor:pointer;", label); 148 + 149 + grid.style.position = "relative"; 150 + 151 + const canvas = el("canvas", "position:absolute;left:0;top:0;display:none;border-radius:6px;z-index:5;touch-action:none;cursor:crosshair;"); 152 + const hudBar = el("div", "position:absolute;left:0;right:0;top:0;display:none;padding:3px 54px 3px 5px;white-space:nowrap;overflow:hidden;font-family:ui-monospace,Menlo,monospace;font-size:10px;color:#e2e8f0;background:linear-gradient(#0b1220ee,#0b122000);border-radius:6px 6px 0 0;z-index:6;pointer-events:none;"); 153 + const overlayMsg = el("div", "position:absolute;left:0;top:0;display:none;flex-direction:column;align-items:center;justify-content:center;gap:9px;text-align:center;padding:14px;border-radius:6px;background:rgba(2,6,23,0.86);color:#e2e8f0;z-index:7;"); 154 + grid.append(canvas, hudBar, overlayMsg); 155 + // always-on-board fast-forward toggle (top-right) so speed is reachable during any wave 156 + const ffBtn = el("button", "position:absolute;right:4px;top:3px;z-index:6;display:none;font-family:ui-monospace,Menlo,monospace;font-size:10px;padding:2px 6px;border-radius:6px;border:1px solid rgba(120,120,135,0.55);background:rgba(2,6,23,0.85);color:#e2e8f0;cursor:pointer;", "⏩ 1×"); 157 + grid.appendChild(ffBtn); 158 + const ctx = canvas.getContext("2d"); 159 + 160 + // controls live below the punchcard; only the board overlays it 161 + const ui = el("div", "font-family:ui-monospace,Menlo,monospace;margin-top:10px;"); 162 + const playBtn = btn("▶ play tower defense"); 163 + playBtn.style.borderColor = "#22c55e"; playBtn.style.color = "#4ade80"; 164 + ui.appendChild(playBtn); 165 + 166 + const panel = el("div", "display:none;flex-direction:column;gap:6px;"); 167 + const shop = el("div", "display:flex;gap:5px;flex-wrap:wrap;"); 168 + const shopBtns = []; 169 + [["peon", "Peon"], ["frost", "Frost"], ["rapid", "Rapid"]].forEach(([t, label]) => { 170 + const b = btn(label + " " + TOWERS[t].cost); 171 + b.dataset.t = t; 172 + shop.appendChild(b); shopBtns.push(b); 173 + }); 174 + const controls = el("div", "display:flex;gap:5px;flex-wrap:wrap;"); 175 + const pauseBtn = btn("⏸ pause"); 176 + const resetBtn = btn("↺ reset"); 177 + const exitBtn = btn("✕ exit"); 178 + controls.append(pauseBtn, resetBtn, exitBtn); 179 + const tip = el("div", "font-size:9px;color:#64748b;", "build on dark tiles · peon & rapid damage, frost slows · ⏩ or press F to fast-forward"); 180 + panel.append(shop, controls, tip); 181 + ui.appendChild(panel); 182 + parent.insertBefore(ui, grid.nextSibling); 183 + 184 + // ---- state ---- 185 + let active = false, gold, waveNum, enemies, towers, shots, 186 + spawnQueue, spawnTimer, betweenTimer, state, paused, speed, selected, hover, killed, escaped; 187 + 188 + function reset() { 189 + boss = false; 190 + waves = groupWaves(roster); totalEnemies = roster.length; 191 + gold = START_GOLD; waveNum = 0; 192 + enemies = []; towers = []; shots = []; spawnQueue = []; spawnTimer = 0; betweenTimer = null; 193 + state = "idle"; paused = false; speed = 1; selected = "peon"; hover = null; 194 + killed = 0; escaped = 0; 195 + roster.forEach((e) => { e.dot.style.opacity = ""; }); 196 + showOverlay("start"); sync(); 197 + } 198 + 199 + function enter() { 200 + layout(); 201 + const dpr = Math.min(2, window.devicePixelRatio || 1); 202 + canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr); 203 + canvas.style.width = W + "px"; canvas.style.height = H + "px"; 204 + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); 205 + overlayMsg.style.width = W + "px"; overlayMsg.style.height = H + "px"; 206 + canvas.style.display = "block"; hudBar.style.display = "block"; panel.style.display = "flex"; 207 + ffBtn.style.display = "block"; playBtn.style.display = "none"; 208 + active = true; 209 + reset(); 210 + } 211 + function exit() { 212 + active = false; 213 + canvas.style.display = "none"; hudBar.style.display = "none"; overlayMsg.style.display = "none"; 214 + ffBtn.style.display = "none"; panel.style.display = "none"; playBtn.style.display = "inline-block"; 215 + roster.forEach((e) => { e.dot.style.opacity = ""; }); 216 + } 217 + 218 + function dim(src, leaked) { 219 + // keep the punchcard color & size; just fade to show it's been dealt with 220 + if (src.dot) src.dot.style.opacity = leaked ? "0.12" : "0.32"; // boss dots have no home cell 221 + } 222 + function makeEnemy(src) { 223 + // boss dots move at a flat brisk clip (they're all maxed out, so speedFor would crawl) 224 + const spd = (boss ? 1.7 : speedFor(src.count)) * waveSpeed(waveNum); 225 + const hp = boss ? 24 : hpFor(src.count, waveNum); 226 + return { src, dist: 0, x: pts[0].x, y: pts[0].y, count: src.count, 227 + spd, hp, maxHp: hp, reward: boss ? 2 : rewardFor(src.count), color: src.color, rad: RAD, 228 + slowF: 1, slowT: 0, dead: false, leaked: false }; 229 + } 230 + function beginWave() { 231 + if (state !== "idle") return; 232 + spawnQueue = waves[0].list.slice(); 233 + spawnTimer = 0.15; betweenTimer = null; 234 + state = "running"; paused = false; 235 + hideOverlay(); sync(); 236 + } 237 + function startBoss() { 238 + boss = true; 239 + const list = bossRoster(); 240 + waves = groupWaves(list); totalEnemies = list.length; 241 + waveNum = 0; enemies = []; shots = []; betweenTimer = null; killed = 0; escaped = 0; 242 + spawnQueue = waves[0].list.slice(); spawnTimer = 0.2; 243 + gold += 200; // reinforcements for the final stand (your towers carry over) 244 + state = "running"; paused = false; 245 + hideOverlay(); sync(); 246 + } 247 + 248 + function updateShop() { 249 + shopBtns.forEach((b) => { 250 + const spec = TOWERS[b.dataset.t], is = b.dataset.t === selected; 251 + b.style.borderColor = is ? "#22c55e" : "rgba(120,120,135,0.4)"; 252 + b.style.color = is ? "#4ade80" : gold < spec.cost ? "#64748b" : "#cbd5e1"; 253 + b.style.boxShadow = is ? "inset 0 0 0 1px #22c55e" : "none"; 254 + b.style.opacity = gold < spec.cost ? "0.6" : "1"; 255 + }); 256 + } 257 + function sync() { 258 + const shown = state === "idle" ? 0 : state === "won" ? waves.length : Math.min(waveNum + 1, waves.length); 259 + let line = "💰" + gold + " 🌊" + shown + "/" + waves.length + 260 + " " + (boss ? "BOSS" : MONTHS[waves[Math.min(waveNum, waves.length - 1)].m]) + 261 + " &nbsp;<span style=\"color:#4ade80\">●</span>" + enemies.length + 262 + " &nbsp;(" + Math.max(0, totalEnemies - killed - escaped) + " left)"; 263 + if (state === "running" && betweenTimer !== null) line += " &nbsp;⏳next " + Math.ceil(betweenTimer) + "s"; 264 + hudBar.innerHTML = line; 265 + pauseBtn.disabled = state !== "running"; 266 + pauseBtn.style.opacity = pauseBtn.disabled ? "0.4" : "1"; 267 + pauseBtn.textContent = paused ? "▶ resume" : "⏸ pause"; 268 + ffBtn.textContent = "⏩ " + speed + "×"; 269 + updateShop(); 270 + } 271 + function showOverlay(kind) { 272 + overlayMsg.style.display = "flex"; overlayMsg.innerHTML = ""; 273 + const h = el("div", "font-size:15px;font-weight:600;"); 274 + const p = el("div", "font-size:10px;line-height:1.5;color:#cbd5e1;max-width:195px;"); 275 + const row = el("div", "display:flex;gap:6px;flex-wrap:wrap;justify-content:center;"); 276 + const add = (label, fn, accent) => { 277 + const b = btn(label); 278 + if (accent) { b.style.borderColor = "#22c55e"; b.style.color = "#4ade80"; } 279 + b.onclick = fn; row.appendChild(b); 280 + }; 281 + if (kind === "start") { 282 + h.textContent = "Tower defense"; 283 + p.innerHTML = "<b>" + totalEnemies + "</b> commit-days march the track over <b>" + waves.length + 284 + "</b> waves, each faster than the last. Brighter dots pack more commits and take more hits. Let a single one reach the exit and it's over."; 285 + add("⚔ line them up", beginWave, true); 286 + } else if (kind === "won") { 287 + h.textContent = "You won! 🎉"; h.style.color = "#4ade80"; 288 + p.innerHTML = "Are you ready to fight " + BOSS_NAME + ", the final boss?"; 289 + add("I think so. Wait.. more than 25k commits..?", startBoss, true); 290 + add("No, but throw it at me anyway", startBoss); 291 + } else if (kind === "bossWon") { 292 + h.textContent = "Final boss down"; h.style.color = "#facc15"; 293 + p.innerHTML = "You held the line against " + BOSS_NAME + "'s onslaught. <b>" + BOSS_COMMITS.toLocaleString("en-US") + 294 + "</b> commits and not one got through."; 295 + add("↺ play again", reset, true); 296 + } else { 297 + h.textContent = "One slipped through 💥"; h.style.color = "#f87171"; 298 + p.innerHTML = "That's all it takes. You crushed <b>" + killed + "</b> before a commit reached the exit."; 299 + add("↺ try again", reset, true); 300 + } 301 + overlayMsg.append(h, p, row); 302 + } 303 + function hideOverlay() { overlayMsg.style.display = "none"; overlayMsg.innerHTML = ""; } 304 + 305 + // ---- simulation ---- 306 + function step(dt) { 307 + const s = dt * speed; 308 + if (spawnQueue.length) { 309 + const gap = boss ? 0.18 : Math.max(0.22, SPAWN_GAP - waveNum * 0.025); // waves flood denser as they go 310 + spawnTimer -= s; 311 + while (spawnTimer <= 0 && spawnQueue.length) { enemies.push(makeEnemy(spawnQueue.shift())); spawnTimer += gap; } 312 + } 313 + for (const e of enemies) { 314 + let m = 1; 315 + if (e.slowT > 0) { m = e.slowF; e.slowT -= s; } 316 + e.dist += e.spd * TILE * m * s; 317 + if (e.dist >= pathLen) e.leaked = true; 318 + else { const q = pointAt(e.dist); e.x = q.x; e.y = q.y; } 319 + } 320 + const gotThrough = enemies.find((e) => e.leaked); 321 + if (gotThrough) { escaped++; dim(gotThrough.src, true); state = "lost"; showOverlay("lost"); sync(); return; } 322 + for (const t of towers) { 323 + t.cd -= s; 324 + if (t.cd > 0) continue; 325 + const spec = TOWERS[t.type], range = spec.range * TILE; 326 + let best = null, bd = -1, rr = range * range; 327 + for (const e of enemies) { 328 + if (e.dead) continue; 329 + if (spec.slow && e.slowT > 0) continue; // frost won't waste a shot re-chilling 330 + const dx = e.x - t.x, dy = e.y - t.y; 331 + if (dx * dx + dy * dy <= rr && e.dist > bd) { best = e; bd = e.dist; } 332 + } 333 + if (best) { shots.push({ x: t.x, y: t.y, target: best, color: spec.proj, dmg: spec.dmg, slow: spec.slow, slowT: spec.slowT, v: 340 }); t.cd = spec.cd; } 334 + else t.cd = 0; 335 + } 336 + for (const p of shots) { 337 + const e = p.target; 338 + if (!e || e.dead) { p.done = true; continue; } 339 + const dx = e.x - p.x, dy = e.y - p.y, d = Math.hypot(dx, dy) || 0.001, adv = p.v * s; 340 + if (d <= adv + e.rad) { 341 + if (p.slow > 0) { e.slowF = p.slow; e.slowT = Math.max(e.slowT, p.slowT); } // frost chills 342 + if (p.dmg > 0) { 343 + e.hp -= p.dmg; 344 + if (e.hp <= 0 && !e.dead) { e.dead = true; gold += e.reward; killed++; dim(e.src, false); } 345 + } 346 + p.done = true; 347 + } else { p.x += dx / d * adv; p.y += dy / d * adv; } 348 + } 349 + enemies = enemies.filter((e) => !e.dead); 350 + shots = shots.filter((p) => !p.done); 351 + if (state === "running" && !spawnQueue.length && !enemies.length) { 352 + if (betweenTimer === null) { 353 + // wave cleared, bank a small bonus, then auto-launch the next after a short breather 354 + if (waveNum + 1 >= waves.length) { state = "won"; showOverlay(boss ? "bossWon" : "won"); } 355 + else { betweenTimer = WAVE_GAP; gold += 8 + (waveNum + 1) * 3; } 356 + } else { 357 + betweenTimer -= s; 358 + if (betweenTimer <= 0) { 359 + betweenTimer = null; 360 + waveNum++; 361 + spawnQueue = waves[waveNum].list.slice(); 362 + spawnTimer = 0.1; 363 + } 364 + } 365 + } 366 + sync(); 367 + } 368 + 369 + // ---- render ---- 370 + function road() { 371 + ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y); 372 + for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y); 373 + ctx.stroke(); 374 + } 375 + function draw() { 376 + ctx.clearRect(0, 0, W, H); 377 + ctx.fillStyle = "rgba(2,6,23,0.62)"; ctx.fillRect(0, 0, W, H); // scrim: mutes the real dots behind 378 + ctx.lineJoin = "round"; ctx.lineCap = "round"; 379 + ctx.strokeStyle = "rgba(30,41,59,0.92)"; ctx.lineWidth = TILE * 0.66; road(); 380 + ctx.strokeStyle = "rgba(148,163,184,0.5)"; ctx.lineWidth = 2; ctx.setLineDash([4, 7]); road(); ctx.setLineDash([]); 381 + ctx.fillStyle = "rgba(148,163,184,0.16)"; 382 + for (const [c, r] of buildable) { 383 + if (towers.some((t) => t.tc === c && t.tr === r)) continue; 384 + ctx.beginPath(); ctx.arc(c * TILE + TILE / 2, r * TILE + TILE / 2, 1.4, 0, 7); ctx.fill(); 385 + } 386 + if (hover && state !== "won" && state !== "lost") { 387 + const { c, r } = hover; 388 + if (c >= 0 && c < COLS && r >= 0 && r < ROWS) { 389 + const occ = blocked.has(keyOf(c, r)) || towers.some((t) => t.tc === c && t.tr === r); 390 + const spec = TOWERS[selected], ok = !occ && gold >= spec.cost; 391 + const cx = c * TILE + TILE / 2, cy = r * TILE + TILE / 2; 392 + ctx.beginPath(); ctx.arc(cx, cy, spec.range * TILE, 0, 7); 393 + ctx.fillStyle = ok ? "rgba(74,222,128,0.10)" : "rgba(248,113,113,0.10)"; 394 + ctx.strokeStyle = ok ? "rgba(74,222,128,0.5)" : "rgba(248,113,113,0.5)"; ctx.lineWidth = 1.5; 395 + ctx.fill(); ctx.stroke(); 396 + ctx.fillStyle = ok ? "rgba(74,222,128,0.4)" : "rgba(248,113,113,0.4)"; 397 + ctx.fillRect(c * TILE + 4, r * TILE + 4, TILE - 8, TILE - 8); 398 + } 399 + } 400 + for (const t of towers) { 401 + const spec = TOWERS[t.type]; 402 + ctx.fillStyle = "rgba(15,23,42,0.95)"; ctx.fillRect(t.tc * TILE + 3, t.tr * TILE + 3, TILE - 6, TILE - 6); 403 + ctx.strokeStyle = spec.body; ctx.lineWidth = 1.5; ctx.strokeRect(t.tc * TILE + 3, t.tr * TILE + 3, TILE - 6, TILE - 6); 404 + ctx.beginPath(); ctx.arc(t.x, t.y, TILE * 0.22, 0, 7); ctx.fillStyle = spec.body; ctx.fill(); 405 + } 406 + for (const p of shots) { ctx.beginPath(); ctx.arc(p.x, p.y, 2.5, 0, 7); ctx.fillStyle = p.color; ctx.fill(); } 407 + for (const e of enemies) { 408 + ctx.beginPath(); ctx.arc(e.x, e.y, e.rad, 0, 7); ctx.fillStyle = e.color; ctx.fill(); 409 + if (e.slowT > 0) { ctx.lineWidth = 1.5; ctx.strokeStyle = "#67e8f9"; ctx.stroke(); } // chilled 410 + if (e.hp < e.maxHp) { 411 + const bw = e.rad * 2.4, bx = e.x - bw / 2, by = e.y - e.rad - 5; 412 + ctx.fillStyle = "#0f172a"; ctx.fillRect(bx, by, bw, 2.5); 413 + ctx.fillStyle = "#22c55e"; ctx.fillRect(bx, by, bw * Math.max(0, e.hp / e.maxHp), 2.5); 414 + } 415 + } 416 + ctx.fillStyle = "#64748b"; ctx.font = "700 9px ui-monospace,monospace"; 417 + ctx.fillText("IN", 3, pts[0].y + 3); 418 + const last = pts[pts.length - 1]; 419 + ctx.fillText("OUT", Math.min(W - 22, last.x - 8), last.y - 6); 420 + } 421 + 422 + // ---- input ---- 423 + const toTile = (ev) => { 424 + const b = canvas.getBoundingClientRect(); 425 + const x = (ev.clientX - b.left) * (W / b.width), y = (ev.clientY - b.top) * (H / b.height); 426 + return { c: Math.floor(x / TILE), r: Math.floor(y / TILE) }; 427 + }; 428 + canvas.addEventListener("pointermove", (e) => { hover = toTile(e); }, { signal: sig }); 429 + canvas.addEventListener("pointerleave", () => { hover = null; }, { signal: sig }); 430 + canvas.addEventListener("pointerdown", (e) => { 431 + e.preventDefault(); 432 + if (state === "won" || state === "lost") return; 433 + const t = toTile(e); hover = t; 434 + if (t.c < 0 || t.c >= COLS || t.r < 0 || t.r >= ROWS) return; 435 + if (blocked.has(keyOf(t.c, t.r))) return; 436 + if (towers.some((w) => w.tc === t.c && w.tr === t.r)) return; 437 + const spec = TOWERS[selected]; 438 + if (gold < spec.cost) return; 439 + gold -= spec.cost; 440 + towers.push({ tc: t.c, tr: t.r, x: t.c * TILE + TILE / 2, y: t.r * TILE + TILE / 2, type: selected, cd: 0 }); 441 + sync(); 442 + }, { signal: sig }); 443 + shopBtns.forEach((b) => b.addEventListener("click", () => { selected = b.dataset.t; updateShop(); }, { signal: sig })); 444 + playBtn.addEventListener("click", enter, { signal: sig }); 445 + exitBtn.addEventListener("click", exit, { signal: sig }); 446 + pauseBtn.addEventListener("click", () => { if (state === "running") { paused = !paused; sync(); } }, { signal: sig }); 447 + const bumpSpeed = () => { speed = speed >= 4 ? 1 : speed + 1; sync(); }; 448 + ffBtn.addEventListener("click", bumpSpeed, { signal: sig }); 449 + window.addEventListener("keydown", (e) => { 450 + if (!active) return; 451 + if (e.key === "f" || e.key === "F") bumpSpeed(); 452 + else if (e.key === " " && state === "running") { e.preventDefault(); paused = !paused; sync(); } 453 + }, { signal: sig }); 454 + resetBtn.addEventListener("click", reset, { signal: sig }); 455 + 456 + // ---- loop ---- 457 + let last = null, raf = 0, alive = true; 458 + function frame(t) { 459 + if (!alive) return; 460 + raf = requestAnimationFrame(frame); 461 + if (!active) { last = t; return; } 462 + if (last === null) last = t; 463 + let dt = (t - last) / 1000; last = t; 464 + if (dt > 0.05) dt = 0.05; 465 + if (state === "running" && !paused) step(dt); 466 + draw(); 467 + } 468 + raf = requestAnimationFrame(frame); 469 + 470 + // ---- teardown (called before re-binding on the next htmx swap) ---- 471 + return () => { 472 + alive = false; 473 + cancelAnimationFrame(raf); 474 + ac.abort(); 475 + ui.remove(); 476 + canvas.remove(); hudBar.remove(); overlayMsg.remove(); ffBtn.remove(); 477 + grid.style.position = ""; 478 + delete grid.dataset.tdOn; 479 + roster.forEach((e) => { e.dot.style.opacity = ""; }); 480 + }; 481 + }
+347
appview/pages/profile-fx/wilb.me.js
··· 1 + // atfeed.js — punchcard profile effect 2 + // 3 + // Swaps the punchcard for a small RSS-style feed of the profile owner's 4 + // atproto records: Leaflet documents (both the original pub.leaflet.* and 5 + // the newer site.standard.* lexicons) and Bluesky posts, merged and sorted 6 + // newest-first. The profile owner's DID is read off the page itself (the 7 + // followers span carries it as data-followers-did). Everything is fetched 8 + // client-side from the public PDS; if anything fails, the punchcard is 9 + // simply left alone. 10 + 11 + const DID_SELECTOR = "#followers[data-followers-did]"; 12 + const PLC_DIRECTORY = "https://plc.directory"; 13 + 14 + const FEED_LIMIT = 12; // entries shown 15 + const PER_COLLECTION = 25; // records fetched per collection 16 + const SNIPPET_LEN = 140; // max chars for post/description text 17 + const STAGGER_MS = 45; // per-entry entrance delay 18 + 19 + const DARK_QUERY = "(prefers-color-scheme: dark)"; 20 + const REDUCED_QUERY = "(prefers-reduced-motion: reduce)"; 21 + 22 + // Collections worth putting in a feed, with how to read each one. 23 + // `docs` sources look up their publication record to build a public URL. 24 + const SOURCES = { 25 + "pub.leaflet.document": { label: "leaflet", kind: "doc", pubCollection: "pub.leaflet.publication" }, 26 + "site.standard.document": { label: "leaflet", kind: "doc", pubCollection: "site.standard.publication" }, 27 + "app.bsky.feed.post": { label: "bsky", kind: "post" }, 28 + }; 29 + 30 + const PALETTES = { 31 + light: { 32 + text: "rgb(28 25 23)", 33 + muted: "rgb(120 113 108)", 34 + accent: "rgb(234 88 12)", // RSS orange 35 + border: "rgb(231 229 228)", 36 + tagBg: "rgb(245 245 244)", 37 + }, 38 + dark: { 39 + text: "rgb(231 229 228)", 40 + muted: "rgb(168 162 158)", 41 + accent: "rgb(251 146 60)", 42 + border: "rgb(68 64 60)", 43 + tagBg: "rgb(41 37 36)", 44 + }, 45 + }; 46 + 47 + // --- fetching ------------------------------------------------------------- 48 + 49 + const getJson = async (url, signal) => { 50 + const res = await fetch(url, { signal }); 51 + if (!res.ok) throw new Error(`${res.status} ${url}`); 52 + return res.json(); 53 + }; 54 + 55 + const xrpc = (pds, method, params) => 56 + `${pds}/xrpc/${method}?${new URLSearchParams(params)}`; 57 + 58 + const resolveIdentity = async (did, signal) => { 59 + const doc = await getJson(`${PLC_DIRECTORY}/${did}`, signal); 60 + const svc = (doc.service || []).find( 61 + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 62 + ); 63 + if (!svc) throw new Error("no PDS in DID document"); 64 + const aka = (doc.alsoKnownAs || [])[0] || ""; 65 + return { 66 + pds: svc.serviceEndpoint, 67 + handle: aka.startsWith("at://") ? aka.slice(5) : did, 68 + }; 69 + }; 70 + 71 + const listRecords = (pds, did, collection, limit, signal) => 72 + getJson( 73 + xrpc(pds, "com.atproto.repo.listRecords", { repo: did, collection, limit }), 74 + signal, 75 + ).then((r) => r.records || []); 76 + 77 + // --- record → feed entry --------------------------------------------------- 78 + 79 + const rkeyOf = (uri) => uri.split("/").pop(); 80 + 81 + const truncate = (s) => { 82 + const t = (s || "").replace(/\s+/g, " ").trim(); 83 + return t.length > SNIPPET_LEN ? `${t.slice(0, SNIPPET_LEN - 1)}…` : t; 84 + }; 85 + 86 + const ensureHttps = (u) => (/^https?:\/\//.test(u) ? u : `https://${u}`); 87 + 88 + // Public URL for a document, via its publication's url/base_path. 89 + const docUrl = (record, pubMap) => { 90 + const v = record.value; 91 + if (typeof v.url === "string") return ensureHttps(v.url); 92 + const base = pubMap.get(v.publication); 93 + if (base) return `${ensureHttps(base).replace(/\/+$/, "")}/${rkeyOf(record.uri)}`; 94 + return `https://pdsls.dev/at://${record.uri.slice(5)}`; // browse the raw record 95 + }; 96 + 97 + const toEntry = (record, source, pubMap, did) => { 98 + const v = record.value || {}; 99 + const when = new Date(v.publishedAt || v.createdAt || 0); 100 + if (source.kind === "doc") { 101 + return { 102 + when, 103 + label: source.label, 104 + title: v.title || v.name || "(untitled document)", 105 + snippet: truncate(v.description), 106 + href: docUrl(record, pubMap), 107 + }; 108 + } 109 + // Bluesky post: skip replies, use the text as the body. 110 + if (v.reply) return null; 111 + return { 112 + when, 113 + label: source.label, 114 + title: truncate(v.text) || "(media post)", 115 + snippet: "", 116 + href: `https://witchsky.app/profile/${did}/post/${rkeyOf(record.uri)}`, 117 + }; 118 + }; 119 + 120 + const loadFeed = async (did, signal) => { 121 + const identity = await resolveIdentity(did, signal); 122 + const { pds } = identity; 123 + 124 + // Only query collections that actually exist in the repo. 125 + const info = await getJson( 126 + xrpc(pds, "com.atproto.repo.describeRepo", { repo: did }), 127 + signal, 128 + ); 129 + const present = new Set(info.collections || []); 130 + const wanted = Object.keys(SOURCES).filter((c) => present.has(c)); 131 + if (wanted.length === 0) throw new Error("no feed-worthy collections"); 132 + 133 + // Publication records (small collections) → map at-uri → base url. 134 + const pubCollections = [ 135 + ...new Set( 136 + wanted 137 + .map((c) => SOURCES[c].pubCollection) 138 + .filter((c) => c && present.has(c)), 139 + ), 140 + ]; 141 + const pubMap = new Map(); 142 + const pubResults = await Promise.allSettled( 143 + pubCollections.map((c) => listRecords(pds, did, c, 50, signal)), 144 + ); 145 + pubResults.forEach((r) => { 146 + if (r.status !== "fulfilled") return; 147 + r.value.forEach((rec) => { 148 + const base = rec.value?.url || rec.value?.base_path; 149 + if (base) pubMap.set(rec.uri, base); 150 + }); 151 + }); 152 + 153 + // The records themselves; PDS returns newest-first by default. 154 + const results = await Promise.allSettled( 155 + wanted.map((c) => listRecords(pds, did, c, PER_COLLECTION, signal)), 156 + ); 157 + const entries = results.flatMap((r, i) => 158 + r.status === "fulfilled" 159 + ? r.value 160 + .map((rec) => toEntry(rec, SOURCES[wanted[i]], pubMap, did)) 161 + .filter(Boolean) 162 + : [], 163 + ); 164 + entries.sort((a, b) => b.when - a.when); 165 + return { identity, entries: entries.slice(0, FEED_LIMIT) }; 166 + }; 167 + 168 + // --- rendering -------------------------------------------------------------- 169 + 170 + const relativeDate = (d) => { 171 + const s = (Date.now() - d.getTime()) / 1000; 172 + if (!Number.isFinite(s) || s < 0 || s > 30 * 86400) { 173 + return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }); 174 + } 175 + if (s < 3600) return `${Math.max(1, Math.floor(s / 60))}m ago`; 176 + if (s < 86400) return `${Math.floor(s / 3600)}h ago`; 177 + return `${Math.floor(s / 86400)}d ago`; 178 + }; 179 + 180 + const el = (tag, styles, text) => { 181 + const node = document.createElement(tag); 182 + Object.assign(node.style, styles); 183 + if (text != null) node.textContent = text; // records are untrusted: text only 184 + return node; 185 + }; 186 + 187 + const render = (grid, { identity, entries }, colors, animate, height) => { 188 + const root = el("div", { 189 + fontSize: "13px", 190 + lineHeight: "1.45", 191 + color: colors.text, 192 + height: height ? `${height}px` : "auto", 193 + overflowY: "auto", 194 + paddingRight: "4px", 195 + boxSizing: "border-box", 196 + }); 197 + root.setAttribute("data-punchcard-feed", ""); 198 + 199 + const header = el("div", { 200 + display: "flex", 201 + alignItems: "center", 202 + gap: "6px", 203 + padding: "2px 0 8px", 204 + borderBottom: `1px solid ${colors.border}`, 205 + fontFamily: "ui-monospace, monospace", 206 + fontSize: "12px", 207 + color: colors.muted, 208 + }); 209 + header.appendChild(el("span", {}, `@${identity.handle} · ${entries.length} entries`)); 210 + root.appendChild(header); 211 + 212 + entries.forEach((entry, i) => { 213 + const row = el("div", { 214 + padding: "8px 0", 215 + borderBottom: `1px solid ${colors.border}`, 216 + }); 217 + 218 + const meta = el("div", { 219 + display: "flex", 220 + alignItems: "baseline", 221 + gap: "8px", 222 + fontFamily: "ui-monospace, monospace", 223 + fontSize: "11px", 224 + color: colors.muted, 225 + }); 226 + meta.appendChild(el("span", {}, relativeDate(entry.when))); 227 + meta.appendChild( 228 + el( 229 + "span", 230 + { 231 + background: colors.tagBg, 232 + border: `1px solid ${colors.border}`, 233 + borderRadius: "9999px", 234 + padding: "0 7px", 235 + }, 236 + entry.label, 237 + ), 238 + ); 239 + row.appendChild(meta); 240 + 241 + const link = el("a", { 242 + display: "block", 243 + marginTop: "2px", 244 + color: colors.text, 245 + textDecoration: "none", 246 + fontWeight: "550", 247 + overflowWrap: "anywhere", 248 + }); 249 + link.href = entry.href; 250 + link.target = "_blank"; 251 + link.rel = "noopener noreferrer"; 252 + link.textContent = entry.title; 253 + link.addEventListener("pointerenter", () => { 254 + link.style.color = colors.accent; 255 + link.style.textDecoration = "underline"; 256 + }); 257 + link.addEventListener("pointerleave", () => { 258 + link.style.color = colors.text; 259 + link.style.textDecoration = "none"; 260 + }); 261 + row.appendChild(link); 262 + 263 + if (entry.snippet) { 264 + row.appendChild(el("div", { color: colors.muted, marginTop: "1px" }, entry.snippet)); 265 + } 266 + 267 + if (animate) { 268 + row.style.opacity = "0"; 269 + row.style.transform = "translateY(4px)"; 270 + row.style.transition = "opacity 300ms ease, transform 300ms ease"; 271 + row.style.transitionDelay = `${i * STAGGER_MS}ms`; 272 + requestAnimationFrame(() => 273 + requestAnimationFrame(() => { 274 + row.style.opacity = "1"; 275 + row.style.transform = "none"; 276 + }), 277 + ); 278 + } 279 + root.appendChild(row); 280 + }); 281 + 282 + return root; 283 + }; 284 + 285 + // --- lifecycle --------------------------------------------------------------- 286 + 287 + const attach = (grid) => { 288 + // The profile page carries the owner's DID on the followers count. 289 + const did = document.querySelector(DID_SELECTOR)?.dataset.followersDid; 290 + if (!did || !did.startsWith("did:")) return () => {}; 291 + 292 + const ac = new AbortController(); 293 + const dark = matchMedia(DARK_QUERY); 294 + const reduced = matchMedia(REDUCED_QUERY).matches; 295 + 296 + let feedEl = null; 297 + let data = null; 298 + let gridHeight = 0; // measured before the grid is hidden 299 + const savedDisplay = grid.style.display; 300 + 301 + const mount = (animate) => { 302 + if (!data) return; 303 + if (!feedEl) gridHeight = grid.getBoundingClientRect().height; 304 + const colors = dark.matches ? PALETTES.dark : PALETTES.light; 305 + const next = render(grid, data, colors, animate && !reduced, gridHeight); 306 + if (feedEl) { 307 + feedEl.replaceWith(next); 308 + } else { 309 + grid.insertAdjacentElement("afterend", next); 310 + grid.style.display = "none"; // punchcard stays intact underneath 311 + } 312 + feedEl = next; 313 + }; 314 + 315 + // Re-render with the other palette when the color scheme flips. 316 + dark.addEventListener("change", () => mount(false), { signal: ac.signal }); 317 + 318 + loadFeed(did, ac.signal) 319 + .then((d) => { 320 + if (ac.signal.aborted || d.entries.length === 0) return; 321 + data = d; 322 + mount(true); 323 + }) 324 + .catch(() => { 325 + /* network/CORS/empty repo: leave the punchcard as it was */ 326 + }); 327 + 328 + return () => { 329 + ac.abort(); 330 + if (feedEl) feedEl.remove(); 331 + grid.style.display = savedDisplay; 332 + }; 333 + }; 334 + 335 + let cleanup = null; 336 + let current = null; 337 + 338 + const init = () => { 339 + const grid = document.querySelector("[data-punchcard]"); 340 + if (grid === current) return; 341 + if (cleanup) cleanup(); 342 + current = grid; 343 + cleanup = grid ? attach(grid) : null; 344 + }; 345 + 346 + init(); 347 + document.addEventListener("htmx:load", init);
+60
appview/pages/profile-fx/willow.sh.js
··· 1 + const styles = ` 2 + [data-punchcard] { 3 + .bg-green-100, .dark\\:bg-green-100 { --w-size: 20%; } 4 + .bg-green-200, .dark\\:bg-green-200 { --w-size: 30%; } 5 + .bg-green-300, .dark\\:bg-green-300 { --w-size: 40%; } 6 + .bg-green-400, .dark\\:bg-green-400 { --w-size: 50%; } 7 + .bg-green-500, .dark\\:bg-green-500 { --w-size: 60%; } 8 + .bg-green-600, .dark\\:bg-green-600 { --w-size: 70%; } 9 + .bg-green-700, .dark\\:bg-green-700 { --w-size: 80%; } 10 + .bg-green-800, .dark\\:bg-green-800 { --w-size: 90%; } 11 + .bg-green-900, .dark\\:bg-green-900 { --w-size: 100%; } 12 + 13 + > div > div { 14 + background-color: color-mix(in srgb, #2160ec var(--w-size), light-dark(white, black)); 15 + transition: transform 0.2s ease-out; 16 + will-change: transform; 17 + } 18 + } 19 + `; 20 + 21 + const sheet = new CSSStyleSheet(); 22 + sheet.replaceSync(styles); 23 + document.adoptedStyleSheets = [sheet]; 24 + 25 + const RADIUS = 60; 26 + const MAX_SCALE = 2.5; 27 + 28 + const grid = document.querySelector('[data-punchcard]'); 29 + const dots = Array.from(grid.querySelectorAll(':scope > div > div')); 30 + 31 + function mousemove(e) { 32 + for (const dot of dots) { 33 + const rect = dot.getBoundingClientRect(); 34 + const cx = rect.left + rect.width / 2; 35 + const cy = rect.top + rect.height / 2; 36 + const dist = Math.hypot(e.clientX - cx, e.clientY - cy); 37 + const scale = 1 + (MAX_SCALE - 1) * Math.max(0, 1 - dist / RADIUS); 38 + dot.style.transform = `scale(${scale})`; 39 + } 40 + } 41 + 42 + function mouseleave() { 43 + for (const dot of dots) { 44 + dot.style.transform = 'scale(1)'; 45 + } 46 + } 47 + 48 + function toggleAnimation(enabled) { 49 + if (enabled) { 50 + grid.addEventListener('mousemove', mousemove); 51 + grid.addEventListener('mouseleave', mouseleave); 52 + } else { 53 + grid.removeEventListener('mousemove', mousemove); 54 + grid.removeEventListener('mouseleave', mouseleave); 55 + } 56 + } 57 + 58 + const motion = window.matchMedia('(prefers-reduced-motion: reduce)'); 59 + toggleAnimation(!motion.matches); 60 + motion.addEventListener('change', (event) => toggleAnimation(!event.matches));
+9
appview/state/profile.go
··· 55 55 56 56 var profileScripts = map[string]string{ 57 57 "did:plc:3fwecdnvtcscjnrx2p4n7alz": "/static/profile-fx/orrery.js", 58 + "did:plc:qfpnj4og54vl56wngdriaxug": "/static/profile-fx/oppili.js", 59 + "did:plc:oxdlsmnvpk2riyyuvq5jtdkd": "/static/profile-fx/wilb.me.js", 60 + "did:plc:lrphxvv25aibthe7xoc2eeyy": "/static/profile-fx/kandake.js", 61 + "did:plc:vzl336yxfrftoc23ygcqklzr": "/static/profile-fx/gdorsi.bsky.social.js", 62 + "did:plc:laqygfbyvnkyuhsuaxmp6ez3": "/static/profile-fx/punchcard-td.js", 63 + "did:plc:dfkjiu36xs6ogt7pux7i7o2b": "/static/profile-fx/willow.sh.js", 64 + "did:plc:doe7nkqeodssh6uc5jcq5iyw": "/static/profile-fx/luisstd.js", 65 + "did:plc:f2ablw5m3ashhpydt6u7h2rx": "/static/profile-fx/chancey.dev.js", 66 + "did:plc:4aah6pidgmlp36cvtispnwhu": "/static/profile-fx/matthewlipski.tngl.sh.js", 58 67 } 59 68 60 69 func (s *State) profile(r *http.Request) (*pages.ProfileCard, error) {