EMERGENT

This private room name allows you to re-enter the same game, or share with friends if you want others to join.

Buy the physical game

Card Library

Shape
Movement
Pattern
Library
`; // Use a Blob URL — more reliable than document.write across async boundaries const blob = new Blob([html], { type: 'text/html' }); const blobUrl = URL.createObjectURL(blob); if (summaryWin && !summaryWin.closed) { summaryWin.location.href = blobUrl; summaryWin.focus(); } else { // Fallback: open fresh if the window was blocked or closed window.open(blobUrl, '_blank'); } // Revoke after a delay to allow the window to load setTimeout(() => URL.revokeObjectURL(blobUrl), 60000); } // Copy invite link (topbar button — may not exist if replaced by desktop menu) if (copyInviteBtn) copyInviteBtn.addEventListener('click', () => { const url = location.href; navigator.clipboard.writeText(url).then(() => { copyInviteBtn.classList.add('copied'); copyInviteLabel.textContent = 'Copied!'; setTimeout(() => { copyInviteBtn.classList.remove('copied'); copyInviteLabel.textContent = 'Invite'; }, 2000); }).catch(() => { // Fallback: select a temp input const tmp = document.createElement('input'); tmp.value = url; document.body.appendChild(tmp); tmp.select(); document.execCommand('copy'); tmp.remove(); copyInviteBtn.classList.add('copied'); copyInviteLabel.textContent = 'Copied!'; setTimeout(() => { copyInviteBtn.classList.remove('copied'); copyInviteLabel.textContent = 'Invite'; }, 2000); }); }); // ── Pre-fill lobby from localStorage if session was active ────────────── (function tryAutoRejoin() { let saved = null; try { saved = JSON.parse(localStorage.getItem('emergent_session') || 'null'); } catch(e) {} // Invite link hash takes priority for room name, but still restore name from session if (location.hash) { inputRoom.value = sanitizeText(decodeURIComponent(location.hash.slice(1)), 32); if (saved && saved.myName && saved.myName !== 'Player') { inputName.value = saved.myName; } return; } if (saved && saved.roomId && saved.myName && saved.sessionActive && saved.myName !== 'Player' && saved.roomId !== 'default') { inputName.value = saved.myName; inputRoom.value = saved.roomId; joinBtn.textContent = 'Re-enter'; } })(); // Lobby is visible from page load — animate the form in requestAnimationFrame(() => triggerLobbyAnimation()); // Update button label as fields change function updateJoinBtnLabel() { let saved = null; try { saved = JSON.parse(localStorage.getItem('emergent_session') || 'null'); } catch(e) {} const nameMatch = saved && saved.myName && inputName.value.trim() === saved.myName; const roomMatch = saved && saved.roomId && inputRoom.value.trim() === saved.roomId; joinBtn.textContent = (nameMatch && roomMatch && saved.sessionActive) ? 'Re-enter' : 'Enter'; } inputName.addEventListener('input', updateJoinBtnLabel); inputRoom.addEventListener('input', updateJoinBtnLabel); // ── Sidebar lobby button ───────────────────────────────────────────────── // newRoom=false → prefill current room name (user is just stepping out, room stays) // newRoom=true → prefill a fresh room name (room is being ended) function goToLobby(msg, newRoom) { if (!appEl || appEl.classList.contains('hidden')) return; // already in lobby const savedRoomId = roomId; // capture before clearing const savedMyName = myName; // capture before clearing if (socket && roomId && !newRoom) socket.emit('leaveRoom'); stopRoomPoll(); roomId = null; players = {}; cards = {}; notes = []; cardHistory = {}; questions = []; activeQuestionId = null; highlightedQuestionId = null; board.innerHTML = ''; updateQuestionBar(); renderQuestionHistory(); updateCardCount(); renderPlayers(); appEl.classList.add('transitioning-in'); setTimeout(() => { appEl.classList.add('hidden'); appEl.classList.remove('transitioning-in'); lobbyEl.classList.remove('hidden'); lobbyEl.classList.remove('transitioning-out'); document.body.classList.remove('on-game'); triggerLobbyAnimation(true); // Prefill room name: keep current room if just stepping back, new name if ending if (newRoom) { inputRoom.value = suggestRoomName(); } else { // Keep the room name they were just in so they can re-enter easily if (savedRoomId) inputRoom.value = savedRoomId; } // Always prefill name from current session if (savedMyName) inputName.value = savedMyName; updateJoinBtnLabel(); document.body.classList.add('on-lobby'); lobbyError.textContent = msg || ''; if (msg) lobbyError.style.color = '#f0ffff'; history.replaceState({ lobby: true }, '', location.pathname); }, 400); } // ── Room poll (outer scope so goToLobby can call stopRoomPoll) ─────────── let roomPollInterval = null; let currentPollRoomId = null; function stopRoomPoll() { clearInterval(roomPollInterval); roomPollInterval = null; } function startRoomPoll(rid) { stopRoomPoll(); currentPollRoomId = rid; roomPollInterval = setInterval(async () => { if (!currentPollRoomId) return; if (appEl.classList.contains('hidden')) { stopRoomPoll(); return; } try { const res = await fetch('/api/room-exists/' + encodeURIComponent(currentPollRoomId)); const data = await res.json(); if (!data.exists) handleRoomGone(); } catch(e) { /* network blip — try again next interval */ } }, 15000); } function showRoomClosedPopup() { const roomClosedModal = document.getElementById('room-closed-modal'); if (!roomClosedModal) return; if (endSessionModal) { endSessionModal.classList.remove('visible'); document.body.classList.remove('on-modal'); } resetEndModal(); roomClosedModal.classList.add('visible'); } async function checkRoomExists(rid) { if (!rid) return true; try { const res = await fetch('/api/room-exists/' + encodeURIComponent(rid)); const data = await res.json(); return data.exists; } catch(e) { return true; // network blip — assume still exists } } function handleRoomGone() { stopRoomPoll(); clearSavedSession(); showRoomClosedPopup(); } if (goLobbyBtn) goLobbyBtn.addEventListener('click', () => goToLobby()); // ── Theme toggle ───────────────────────────────────────────────────────── const themeToggle = document.getElementById('theme-toggle'); const themeIconDark = document.getElementById('theme-icon-dark'); const themeIconLight = document.getElementById('theme-icon-light'); const lobbyThemeToggle = document.getElementById('lobby-theme-toggle'); const lobbyIconMoon = document.getElementById('lobby-icon-moon'); const lobbyIconSun = document.getElementById('lobby-icon-sun'); const lobbyThemeLabel = document.getElementById('lobby-theme-label'); function applyTheme(mode) { if (mode === 'light') { document.body.classList.remove('dark-mode'); document.body.classList.add('light-mode'); if (themeIconDark) themeIconDark.style.display = 'block'; if (themeIconLight) themeIconLight.style.display = 'none'; if (lobbyIconMoon) lobbyIconMoon.style.display = 'block'; if (lobbyIconSun) lobbyIconSun.style.display = 'none'; if (lobbyThemeLabel) lobbyThemeLabel.textContent = 'Switch to dark mode'; } else { document.body.classList.remove('light-mode'); document.body.classList.add('dark-mode'); if (themeIconDark) themeIconDark.style.display = 'none'; if (themeIconLight) themeIconLight.style.display = 'block'; if (lobbyIconMoon) lobbyIconMoon.style.display = 'none'; if (lobbyIconSun) lobbyIconSun.style.display = 'block'; if (lobbyThemeLabel) lobbyThemeLabel.textContent = 'Switch to light mode'; } localStorage.setItem('emergent_theme', mode); // Rebuild canvas text so colour updates immediately if (typeof window._rebuildLobbyText === 'function') window._rebuildLobbyText(); } // Apply saved or default theme applyTheme(localStorage.getItem('emergent_theme') || 'dark'); // ── Landing → Lobby ────────────────────────────────────────────────────── // (landing screen removed — lobby shown directly on load) const landingEl = null; const landingPlayEl = null; const landingGetEl = null; // Trigger lobby entrance animation const lobbyInner = document.querySelector('.lobby-inner'); // ── Inject "CONSIDER [DIRECTION]" label into .directions div ──────────── function injectConsiderLabel(container) { const dir = container.querySelector('.directions'); if (!dir) return; // Find direction name from class list (skip 'directions') const dirName = Array.from(dir.classList).find(c => c !== 'directions'); if (!dirName) return; // Don't double-inject if (dir.querySelector('.consider-label')) return; const label = document.createElement('span'); label.className = 'consider-label'; label.innerHTML = 'CONSIDER ' + dirName.toUpperCase() + ''; dir.insertBefore(label, dir.firstChild); } function triggerLobbyAnimation(skip) { if (!lobbyInner) return; if (skip) { // Returning from a room — show the form immediately, no animation lobbyInner.classList.remove('animate'); lobbyInner.style.opacity = '1'; lobbyInner.style.transform = 'none'; return; } lobbyInner.style.opacity = ''; lobbyInner.style.transform = ''; lobbyInner.classList.remove('animate'); // Restart wordmark letter animations const letters = document.querySelectorAll('#emergent-wordmark .wl'); letters.forEach(el => { el.style.animation = 'none'; el.style.opacity = '0'; }); requestAnimationFrame(() => requestAnimationFrame(() => { lobbyInner.classList.add('animate'); letters.forEach(el => { el.style.animation = ''; el.style.opacity = ''; }); })); } // ── Wordmark letter bubbles ────────────────────────────────────────────── (function initWordmarkBubbles() { const canvas = document.getElementById('wordmark-bubbles'); if (!canvas) return; const ctx = canvas.getContext('2d'); let W, H; function resize() { W = canvas.width = window.innerWidth; H = canvas.height = window.innerHeight; } resize(); window.addEventListener('resize', resize); // ── Color palette (same as lobby bubble system) ──────────────── const PALETTE_DARK = [[140,200,255],[180,140,255],[100,220,200],[255,200,120]]; const PALETTE_LIGHT = [[ 60,130,200],[120, 80,200],[ 40,160,150],[200,130, 40]]; let phaseIdx = 0, lastPhaseMs = 0; const PHASE_MS = 6000; function lerpColor(alpha) { const isLight = document.body.classList.contains('light-mode'); const PAL = isLight ? PALETTE_LIGHT : PALETTE_DARK; const now = performance.now(); if (!lastPhaseMs) lastPhaseMs = now; let t = (now - lastPhaseMs) / PHASE_MS; if (t >= 1) { t = 0; phaseIdx = (phaseIdx + 1) % PAL.length; lastPhaseMs = now; } const a = PAL[phaseIdx], b = PAL[(phaseIdx + 1) % PAL.length]; const r = (a[0] + (b[0]-a[0])*t) | 0; const g = (a[1] + (b[1]-a[1])*t) | 0; const bv = (a[2] + (b[2]-a[2])*t) | 0; return `rgba(${r},${g},${bv},${alpha})`; } // ── Bubble drawing (soft radial gradient + rim + highlight) ──── function drawBubble(x, y, r, alpha) { const grd = ctx.createRadialGradient(x, y, 0, x, y, r * 2.0); grd.addColorStop(0, lerpColor(alpha * 0.6)); grd.addColorStop(0.5, lerpColor(alpha * 0.15)); grd.addColorStop(1, lerpColor(0)); ctx.beginPath(); ctx.arc(x, y, r * 2.0, 0, Math.PI * 2); ctx.fillStyle = grd; ctx.fill(); ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.strokeStyle = lerpColor(alpha); ctx.lineWidth = 0.8; ctx.stroke(); // tiny highlight ctx.beginPath(); ctx.arc(x - r * 0.32, y - r * 0.32, r * 0.2, 0, Math.PI * 2); ctx.fillStyle = lerpColor(alpha * 0.7); ctx.fill(); } // ── Hover trail — slow-rising gentle bubbles ─────────────────── const trail = []; let trailCooldown = false; function spawnTrail(x, y) { if (trailCooldown) return; trailCooldown = true; setTimeout(() => { trailCooldown = false; }, 80); // spawn 1-2 bubbles, rarely 3 const count = Math.random() > 0.7 ? 2 : 1; for (let i = 0; i < count; i++) { const r = 2 + Math.random() * 5; trail.push({ x: x + (Math.random() - 0.5) * 20, y: y + (Math.random() - 0.5) * 10, r, vx: (Math.random() - 0.5) * 0.4, vy: -(0.4 + Math.random() * 0.7), // gentle upward drift wobble: Math.random() * Math.PI * 2, wSpeed: 0.02 + Math.random() * 0.02, wAmp: 0.4 + Math.random() * 0.8, alpha: 0.45 + Math.random() * 0.3, life: 1, decay: 0.006 + Math.random() * 0.008, // slow fade }); } } // Wire to lobby (so wordmark can stay pointer-events:none and never block clicks) if (lobbyEl) { lobbyEl.addEventListener('mousemove', e => spawnTrail(e.clientX, e.clientY)); lobbyEl.addEventListener('touchmove', e => { const t = e.touches[0]; spawnTrail(t.clientX, t.clientY); }, { passive: true }); } function loop() { ctx.clearRect(0, 0, W, H); for (let i = trail.length - 1; i >= 0; i--) { const b = trail[i]; b.wobble += b.wSpeed; b.x += b.vx + Math.sin(b.wobble) * b.wAmp * 0.08; b.y += b.vy; b.life -= b.decay; if (b.life <= 0) { trail.splice(i, 1); continue; } drawBubble(b.x, b.y, b.r, b.alpha * b.life); } requestAnimationFrame(loop); } loop(); })(); (function initBubbles() { return; // bubbles disabled const canvas = document.getElementById('bubble-canvas'); if (!canvas) return; const ctx = canvas.getContext('2d'); let W = 0, H = 0; function resize() { const lobby = document.getElementById('lobby'); if (!lobby) return; W = canvas.width = lobby.offsetWidth || window.innerWidth; H = canvas.height = lobby.offsetHeight || window.innerHeight; buildTextCanvas(); } // ── Color palette ────────────────────────────────────────────── const PALETTE_DARK = [[140,200,255],[180,140,255],[100,220,200],[255,200,120]]; const PALETTE_LIGHT = [[ 60,130,200],[120, 80,200],[ 40,160,150],[200,130, 40]]; let phaseIdx = 0, lastPhaseMs = 0; const PHASE_MS = 6000; function lerpColor(alpha) { const isLight = document.body.classList.contains('light-mode'); const PAL = isLight ? PALETTE_LIGHT : PALETTE_DARK; const now = performance.now(); if (!lastPhaseMs) lastPhaseMs = now; let t = (now - lastPhaseMs) / PHASE_MS; if (t >= 1) { t = 0; phaseIdx = (phaseIdx + 1) % PAL.length; lastPhaseMs = now; } const a = PAL[phaseIdx], b = PAL[(phaseIdx + 1) % PAL.length]; const r = (a[0] + (b[0]-a[0])*t) | 0; const g = (a[1] + (b[1]-a[1])*t) | 0; const bv = (a[2] + (b[2]-a[2])*t) | 0; return `rgba(${r},${g},${bv},${alpha})`; } // ── Offscreen text canvas ────────────────────────────────────── const offscreen = document.createElement('canvas'); const octx = offscreen.getContext('2d'); let textY = 0, textX = 0, fontSize = 0; function buildTextCanvas() { offscreen.width = W; offscreen.height = H; octx.clearRect(0, 0, W, H); fontSize = Math.min(Math.max(W * 0.075, 38), 92); octx.font = `700 ${fontSize}px "Josefin Sans", sans-serif`; octx.letterSpacing = '0.08em'; octx.textAlign = 'center'; octx.textBaseline = 'middle'; const isLight = document.body.classList.contains('light-mode'); // Barely visible — just a whisper above background octx.fillStyle = isLight ? 'rgba(30,40,80,0.07)' : 'rgba(255,255,255,0.06)'; textX = W / 2; textY = H * 0.20; octx.fillText('COME INTO PLAY', textX, textY); } // ── Blob / soft-focus text ───────────────────────────────────── // Rather than displacing slices, we draw the text multiple times // with tiny random offsets, each at low opacity — this creates a // soft, slightly out-of-focus, blobby quality. The offsets drift // over time via slow sine waves, giving organic movement without // harsh line-slicing artefacts. let blobTime = 0; const BLOB_LAYERS = 5; // number of overlapping ghost copies function drawBlobText() { if (!offscreen.width) return; blobTime += 0.007; const spread = fontSize * 0.018; for (let i = 0; i < BLOB_LAYERS; i++) { const phase = (i / BLOB_LAYERS) * Math.PI * 2; const ox = spread * ( Math.sin(blobTime * 0.8 + phase) * 0.6 + Math.sin(blobTime * 1.3 + phase * 1.7) * 0.4 ); const oy = spread * ( Math.cos(blobTime * 0.7 + phase * 1.2) * 0.6 + Math.cos(blobTime * 1.1 + phase * 0.9) * 0.4 ); ctx.globalAlpha = 0.25 + 0.15 * Math.cos(phase); ctx.drawImage(offscreen, ox, oy); } ctx.globalAlpha = 1; } // ── Bubble drawing ───────────────────────────────────────────── function drawBubble(x, y, r, alpha) { const grd = ctx.createRadialGradient(x, y, 0, x, y, r * 2.0); grd.addColorStop(0, lerpColor(alpha * 0.6)); grd.addColorStop(0.5, lerpColor(alpha * 0.15)); grd.addColorStop(1, lerpColor(0)); ctx.beginPath(); ctx.arc(x, y, r * 2.0, 0, Math.PI * 2); ctx.fillStyle = grd; ctx.fill(); ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.strokeStyle = lerpColor(alpha); ctx.lineWidth = 0.8; ctx.stroke(); ctx.beginPath(); ctx.arc(x - r * 0.32, y - r * 0.32, r * 0.2, 0, Math.PI * 2); ctx.fillStyle = lerpColor(alpha * 0.7); ctx.fill(); } // ── Ambient pool ─────────────────────────────────────────────── const pool = []; function makeBubble() { const speed = 0.45 + Math.random() * 1.1; const r = 1.2 + (1.0 - (speed - 0.45) / 1.1) * 4.5 + Math.random() * 1.5; return { x: Math.random() * W, y: H + r + Math.random() * H, r, speed, wobble: Math.random() * Math.PI * 2, wSpeed: 0.008 + Math.random() * 0.016, wAmp: 0.5 + Math.random() * 1.5, alpha: 0.5 + Math.random() * 0.4, }; } // ── Cursor trail ─────────────────────────────────────────────── const trail = []; let trailCooldown = false; function spawnTrail(x, y) { if (trailCooldown) return; trailCooldown = true; setTimeout(() => { trailCooldown = false; }, 40); for (let i = 0; i < 1 + (Math.random() > 0.6 ? 1 : 0); i++) { const r = 1 + Math.random() * 3; trail.push({ x: x + (Math.random() - 0.5) * 8, y: y + (Math.random() - 0.5) * 8, r, vx: (Math.random() - 0.5) * 0.6, vy: -(0.6 + Math.random() * 1.2), alpha: 0.7 + Math.random() * 0.25, life: 1, decay: 0.018 + Math.random() * 0.014, }); } } const lobbyDiv = document.getElementById('lobby'); if (lobbyDiv) { lobbyDiv.addEventListener('mousemove', (e) => { const rect = canvas.getBoundingClientRect(); spawnTrail(e.clientX - rect.left, e.clientY - rect.top); }); } // ── Burst pool ───────────────────────────────────────────────── const bursts = []; function spawnBurst(x, y) { for (let i = 0; i < 18 + (Math.random() * 10 | 0); i++) { const angle = Math.random() * Math.PI * 2; const spd = 1.0 + Math.random() * 3.2; bursts.push({ x, y, vx: Math.cos(angle) * spd * 0.55, vy: Math.sin(angle) * spd - 2.2, r: 1 + Math.random() * 3, alpha: 0.6 + Math.random() * 0.3, life: 1, decay: 0.010 + Math.random() * 0.016, }); } } // ── Main loop ────────────────────────────────────────────────── function tick() { requestAnimationFrame(tick); if (!W || !H) return; const lobbyEl = document.getElementById('lobby'); if (lobbyEl && lobbyEl.classList.contains('hidden')) return; ctx.clearRect(0, 0, W, H); // 1. Blobby text — faint, organic, drifting drawBlobText(); // 2. Ambient bubbles for (let i = 0; i < pool.length; i++) { const b = pool[i]; b.wobble += b.wSpeed; b.x += Math.sin(b.wobble) * b.wAmp * 0.1; b.y -= b.speed; if (b.y + b.r < 0) { pool[i] = makeBubble(); pool[i].y = H + pool[i].r; } drawBubble(b.x, b.y, b.r, b.alpha); } // 4. Cursor trail for (let i = trail.length - 1; i >= 0; i--) { const b = trail[i]; b.x += b.vx; b.y += b.vy; b.vy -= 0.01; b.life -= b.decay; if (b.life <= 0) { trail.splice(i, 1); continue; } drawBubble(b.x, b.y, b.r, b.alpha * b.life); } // 5. Burst bubbles for (let i = bursts.length - 1; i >= 0; i--) { const b = bursts[i]; b.x += b.vx; b.y += b.vy; b.vy += 0.05; b.life -= b.decay; if (b.life <= 0) { bursts.splice(i, 1); continue; } drawBubble(b.x, b.y, b.r, b.alpha * b.life); } } // ── Init ─────────────────────────────────────────────────────── window._rebuildLobbyText = buildTextCanvas; requestAnimationFrame(() => { resize(); pool.length = 0; for (let i = 0; i < 13; i++) pool.push(makeBubble()); tick(); }); window.addEventListener('resize', () => { resize(); pool.length = 0; for (let i = 0; i < 13; i++) pool.push(makeBubble()); }); })(); if (themeToggle) themeToggle.addEventListener('click', () => { const isDark = document.body.classList.contains('dark-mode'); applyTheme(isDark ? 'light' : 'dark'); }); const lobbyResetBtn = document.getElementById('lobby-reset-btn'); // Show lobby announcement once per user, expires 2026-04-08 (function() { const ann = document.getElementById('lobby-announcement'); if (!ann) return; const expires = new Date('2026-04-08T00:00:00').getTime(); if (Date.now() > expires) return; if (localStorage.getItem('emergent_announcement_seen')) return; ann.style.display = 'block'; })(); if (lobbyResetBtn) lobbyResetBtn.addEventListener('click', () => { clearSavedSession(); inputName.value = ''; inputRoom.value = ''; joinBtn.textContent = 'Enter'; lobbyError.textContent = ''; }); if (lobbyThemeToggle) lobbyThemeToggle.addEventListener('click', () => { const isDark = document.body.classList.contains('dark-mode'); applyTheme(isDark ? 'light' : 'dark'); }); // ── Reset view button (mobile) ─────────────────────────────────────────── function doRecenter() { const items = Object.values(cards); if (!items.length) { boardScale = 1; boardOffX = 0; boardOffY = 0; applyBoardTransform(); return; } const cardPx = getCardSize(); let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; items.forEach(item => { const px = vToP(item.x); const py = vToP(item.y); minX = Math.min(minX, px); minY = Math.min(minY, py); maxX = Math.max(maxX, px + cardPx); maxY = Math.max(maxY, py + cardPx); }); const contentW = maxX - minX; const contentH = maxY - minY; const vw = viewport.clientWidth; const vh = viewport.clientHeight; const padding = 60; const scaleX = (vw - padding * 2) / contentW; const scaleY = (vh - padding * 2) / contentH; boardScale = Math.min(1, scaleX, scaleY); const centerBoardX = minX + contentW / 2; const centerBoardY = minY + contentH / 2; boardOffX = vw / 2 - centerBoardX * boardScale; boardOffY = vh / 2 - centerBoardY * boardScale; applyBoardTransform(); } // Wire both: old floating btn (desktop/tablet) if present, and new tab btn const resetViewBtn = document.getElementById('reset-view-btn'); if (resetViewBtn) resetViewBtn.addEventListener('click', doRecenter); // mob-recenter-btn wired above in tab bar section // ── Back button returns to lobby ──────────────────────────────────────── // Push a baseline history entry so back always has a lobby state to return to history.replaceState({ lobby: true }, '', location.href); window.addEventListener('popstate', (e) => { if (!appEl.classList.contains('hidden')) { goToLobby(); } }); // ── Toast ───────────────────────────────────────────────────────────────── let toastTimer = null; function showToast(msg) { toastEl.textContent = sanitizeText(msg, 120); toastEl.classList.add('show'); clearTimeout(toastTimer); toastTimer = setTimeout(() => toastEl.classList.remove('show'), 3500); } // ── Lobby ───────────────────────────────────────────────────────────────── joinBtn.addEventListener('click', doJoin); inputName.addEventListener('keydown', e => { if (e.key === 'Enter') inputRoom.focus(); }); inputRoom.addEventListener('keydown', e => { if (e.key === 'Enter') doJoin(); }); function doJoin() { try { const name = sanitizeText(inputName.value, 32); const room = sanitizeText(inputRoom.value, 32).toLowerCase(); if (!name) { lobbyError.textContent = 'Please enter your name.'; return; } if (!room) { lobbyError.textContent = 'Please enter a room name.'; return; } lobbyError.style.color = ''; lobbyError.textContent = ''; try { localStorage.setItem('emergent_announcement_seen', '1'); } catch(e) {} console.log('[doJoin] name:', name, 'room:', room); if (!socket) { console.log('[doJoin] creating socket'); initSocket(); socket.once('connect', () => { console.log('[doJoin] connected, emitting joinRoom'); socket.emit('joinRoom', { roomId: room, name }); }); } else if (!socket.connected) { console.log('[doJoin] socket exists but not connected, waiting'); socket.once('connect', () => { console.log('[doJoin] connected, emitting joinRoom'); socket.emit('joinRoom', { roomId: room, name }); }); } else { console.log('[doJoin] socket connected, emitting joinRoom now'); socket.emit('joinRoom', { roomId: room, name }); } } catch(err) { console.error('[doJoin] ERROR:', err); lobbyError.textContent = 'Error: ' + err.message; } } // ── Socket ──────────────────────────────────────────────────────────────── function initSocket() { socket = io(); socket.on('connect', () => { console.log('[socket] connected, id:', socket.id); // If we're already in-game (e.g. after a long disconnect that exceeded Socket.IO's // reconnection window), re-register with the server so draw/emit works again if (roomId && myName && !appEl.classList.contains('hidden')) { console.log('[socket] connect while in-game, re-joining room', roomId); isRejoining = true; socket.emit('joinRoom', { roomId, name: myName }); } }); socket.on('disconnect', (reason) => { console.log('[socket] disconnected:', reason); showToast('Disconnected. Reconnecting…'); }); socket.on('reconnect', async () => { console.log('[socket] reconnected, id:', socket.id); // If we're in-game, re-register with the server — new socket ID means the server // no longer has us in room.players, which breaks admin active status and multiplayer. if (roomId && myName && !appEl.classList.contains('hidden')) { console.log('[socket] re-emitting joinRoom for', roomId); isRejoining = true; socket.emit('joinRoom', { roomId, name: myName }); } // Also verify room still exists (may have been deleted while offline) if (currentPollRoomId && !appEl.classList.contains('hidden')) { const exists = await checkRoomExists(currentPollRoomId); if (!exists) handleRoomGone(); } }); socket.on('connect_error', (err) => { console.error('[socket] connect_error:', err.message); lobbyError.textContent = 'Connection error: ' + err.message; }); socket.on('error', ({ message }) => { console.error('[socket] error event:', message); const msg = sanitizeText(message, 120); appEl.classList.contains('hidden') ? (lobbyError.textContent = msg) : showToast(msg); }); socket.on('roomJoined', ({ roomId: rid, playerId, player, state }) => { console.log('[roomJoined] received', { rid, playerId, player, stateKeys: Object.keys(state||{}), isRejoining }); try { myId = playerId; myColor = player.color; myName = sanitizeText(player.name, 32); roomId = sanitizeText(rid, 32); if (isRejoining) { // Quiet re-join after reconnect — skip the lobby transition but sync state isRejoining = false; console.log('[roomJoined] silent rejoin, syncing state'); // Restore questions questions = (state.questions || []).map(q => ({ ...q, cardIds: q.cardIds || [] })); activeQuestionId = state.activeQuestionId || null; highlightedQuestionId = null; updateQuestionBar(); renderQuestionHistory(); // Clear dock and restore from server state dockedIds.clear(); if (dockCards) dockCards.innerHTML = ''; // Restore cards cards = {}; board.innerHTML = ''; (state.cards || []).forEach(c => { cards[c.id] = c; renderCard(c, false); if (c.docked) dockCardVisual(c.id); }); updateDockHint(); cardHistory = {}; (state.cardHistory || state.cards || []).forEach(c => { cardHistory[c.id] = c; }); updateCardCount(); applyHighlight(); // Restore notes notes = state.notes || []; renderAllNotes(); startRoomPoll(roomId); return; } history.pushState({ inRoom: true }, '', '#' + encodeURIComponent(roomId)); // Lobby fades out, then frame assembles: topbar drops, sidebar slides, viewport fades lobbyEl.classList.add('transitioning-out'); appEl.classList.remove('hidden'); document.body.classList.remove('on-lobby'); document.body.classList.add('on-game'); roomLabel.textContent = roomId; saveSession(); const topbar = document.getElementById('topbar-wrap'); const sidebar = document.getElementById('sidebar'); const viewport = document.getElementById('viewport'); // Set start positions before any transition is active [topbar, sidebar, viewport].forEach(el => { if (el) el.classList.add('frame-start'); }); setTimeout(() => { lobbyEl.classList.add('hidden'); lobbyEl.classList.remove('transitioning-out'); // Apply transitions then remove start positions on next frame so they animate in [topbar, sidebar, viewport].forEach(el => { if (el) el.classList.add('frame-entering'); }); if (sidebar) sidebar.style.transitionDelay = '0.08s'; requestAnimationFrame(() => requestAnimationFrame(() => { [topbar, sidebar, viewport].forEach(el => { if (el) el.classList.remove('frame-start'); }); // Clean up after animation completes setTimeout(() => { [topbar, sidebar, viewport].forEach(el => { if (el) { el.classList.remove('frame-entering'); el.style.transitionDelay = ''; } }); }, 750); })); }, 480); players = {}; (state.players || []).forEach(p => { players[p.id] = { name: sanitizeText(p.name, 32), color: sanitizeText(p.color, 20) }; }); renderPlayers(); // Restore questions questions = (state.questions || []).map(q => ({ ...q, cardIds: q.cardIds || [] })); activeQuestionId = state.activeQuestionId || null; highlightedQuestionId = null; updateQuestionBar(); renderQuestionHistory(); cards = {}; board.innerHTML = ''; dockedIds.clear(); if (dockCards) dockCards.innerHTML = ''; (state.cards || []).forEach(c => { cards[c.id] = c; renderCard(c, false); if (c.docked) dockCardVisual(c.id); }); updateDockHint(); // Restore permanent card history cardHistory = {}; (state.cardHistory || state.cards || []).forEach(c => { cardHistory[c.id] = c; }); updateCardCount(); applyHighlight(); notes = state.notes || []; renderAllNotes(); // Center all cards in viewport after joining setTimeout(doRecenter, 50); startRoomPoll(roomId); console.log('[roomJoined] complete — lobby hidden'); } catch(err) { console.error('[roomJoined] ERROR:', err); lobbyError.textContent = 'Join error: ' + err.message; lobbyEl.classList.remove('hidden'); appEl.classList.add('hidden'); } }); socket.on('playerJoined', ({ id, name, color }) => { players[id] = { name: sanitizeText(name, 32), color: sanitizeText(color, 20) }; renderPlayers(); }); socket.on('playerLeft', ({ id }) => { delete players[id]; renderPlayers(); }); socket.on('cardDrawn', card => { cards[card.id] = card; cardHistory[card.id] = card; // permanent — never removed // Update question's cardIds if this card is associated with a question if (card.questionId) { const q = questions.find(q => q.id === card.questionId); if (q && !q.cardIds.includes(card.id)) q.cardIds.push(card.id); } renderCard(card, true); panToCard(card.x, card.y); // Apply highlight state to this new card if (highlightedQuestionId) { const el = document.getElementById('cw-' + card.id); if (el) { const q = questions.find(q => q.id === highlightedQuestionId); const isHighlighted = q && q.cardIds.includes(card.id); el.setAttribute('data-q-highlight', isHighlighted ? 'on' : 'off'); if (isHighlighted && q) el.style.setProperty('--q-color', q.color); } } renderQuestionHistory(); updateCardCount(); saveSession(); }); socket.on('cardMoved', ({ cardId, x, y }) => { const id = sanitizeCardId(cardId); if (!id || !cards[id]) return; cards[id].x = x; cards[id].y = y; if (dragging && dragging.cardId === id) return; const el = document.getElementById('cw-' + id); if (el) { el.style.left = vToP(x) + 'px'; el.style.top = vToP(y) + 'px'; } }); socket.on('cardRotated', ({ cardId, rotation }) => { const id = sanitizeCardId(cardId); if (!id || !cards[id]) return; cards[id].rotation = rotation; const holder = document.querySelector('#cw-' + id + ' .card-img-holder'); if (holder) holder.style.transform = `rotate(${rotation}deg)`; }); socket.on('cardDocked', ({ cardId }) => { const id = sanitizeCardId(cardId); if (!id || !cards[id]) return; dockCardVisual(id); }); socket.on('cardUndocked', ({ cardId, x, y }) => { const id = sanitizeCardId(cardId); if (!id || !cards[id]) return; undockCardVisual(id); const boardEl = document.getElementById('cw-' + id); if (boardEl) { cards[id].x = x; cards[id].y = y; boardEl.style.left = vToP(x) + 'px'; boardEl.style.top = vToP(y) + 'px'; boardEl.style.zIndex = nextZ(); } }); socket.on('cardRemoved', ({ cardId }) => { const id = sanitizeCardId(cardId); if (!id) return; delete cards[id]; const el = document.getElementById('cw-' + id); if (el) el.remove(); // Also remove from dock if docked if (dockedIds.has(id)) { dockedIds.delete(id); const dockEl = document.getElementById('dock-' + id); if (dockEl) { if (dockEl._dockCleanup) dockEl._dockCleanup(); dockEl.remove(); } updateDockHint(); } updateCardCount(); saveSession(); }); socket.on('noteReceived', note => { notes.push(note); renderNoteMessage(note); saveSession(); }); // ── Room-closed popup ───────────────────────────────────────────────── const roomClosedModal = document.getElementById('room-closed-modal'); const roomClosedRestart = document.getElementById('room-closed-restart'); // Check immediately when tab becomes visible again — catches days-old backgrounded tabs document.addEventListener('visibilitychange', async () => { if (document.hidden) return; // If we're in a game, verify the room still exists if (currentPollRoomId && !appEl.classList.contains('hidden')) { const exists = await checkRoomExists(currentPollRoomId); if (!exists) handleRoomGone(); } }); if (roomClosedRestart) { roomClosedRestart.addEventListener('click', () => { roomClosedModal.classList.remove('visible'); clearSavedSession(); // Force lobby state regardless of what the game view is doing roomId = null; currentPollRoomId = null; stopRoomPoll(); cards = {}; cardHistory = {}; notes = []; questions = []; activeQuestionId = null; highlightedQuestionId = null; board.innerHTML = ''; updateQuestionBar(); renderQuestionHistory(); updateCardCount(); renderPlayers(); appEl.classList.add('hidden'); lobbyEl.classList.remove('hidden'); lobbyEl.classList.remove('transitioning-out'); document.body.classList.remove('on-game'); document.body.classList.add('on-lobby'); inputRoom.value = suggestRoomName(); inputName.value = ''; lobbyError.textContent = ''; triggerLobbyAnimation(); history.replaceState({ lobby: true }, '', location.pathname); }); } socket.on('sessionEnded', () => { // If we're already back in lobby (e.g. skip-without-saving sent us there), ignore if (!appEl || appEl.classList.contains('hidden')) return; handleRoomGone(); }); socket.on('questionAdded', ({ question: q, activeQuestionId: aqId }) => { if (!questions.find(existing => existing.id === q.id)) { questions.push(q); } activeQuestionId = aqId; updateQuestionBar(); renderQuestionHistory(); saveSession(); }); socket.on('activeQuestionCleared', () => { activeQuestionId = null; updateQuestionBar(); renderQuestionHistory(); saveSession(); }); socket.on('allQuestionsCleared', () => { questions = []; activeQuestionId = null; highlightedQuestionId = null; // Clear questionId from live cards and history Object.values(cards).forEach(c => { c.questionId = null; }); Object.values(cardHistory).forEach(c => { c.questionId = null; }); updateQuestionBar(); renderQuestionHistory(); applyHighlight(); saveSession(); }); socket.on('allCardsCleared', () => { cards = {}; board.innerHTML = ''; // Clear dock dockedIds.clear(); if (dockCards) dockCards.innerHTML = ''; updateDockHint(); // cardHistory preserved — cards still exist in history for summary // cardIds on questions preserved — they reference history, not live board highlightedQuestionId = null; renderQuestionHistory(); applyHighlight(); updateCardCount(); saveSession(); }); } // ── Players ─────────────────────────────────────────────────────────────── const playersOverflow = document.getElementById('players-overflow'); const playersOverflowBtn = document.getElementById('players-overflow-btn'); const playersPopover = document.getElementById('players-popover'); let popoverOpen = false; if (playersOverflowBtn) playersOverflowBtn.addEventListener('click', e => { e.stopPropagation(); popoverOpen = !popoverOpen; playersPopover.classList.toggle('visible', popoverOpen); }); document.addEventListener('click', () => { popoverOpen = false; if (playersPopover) playersPopover.classList.remove('visible'); }); function makeDot(id, p) { const dot = document.createElement('div'); dot.className = 'player-dot' + (id === myId ? ' me' : ''); dot.style.background = p.color; const initials = p.name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase(); dot.innerHTML = `${sanitizeForDOM(initials)}${sanitizeForDOM(p.name)}`; return dot; } function renderPlayers() { playersList.innerHTML = ''; if (playersPopover) playersPopover.innerHTML = ''; const MAX_VISIBLE = 3; const entries = Object.entries(players); const visible = entries.slice(0, MAX_VISIBLE); const overflow = entries.slice(MAX_VISIBLE); visible.forEach(([id, p]) => playersList.appendChild(makeDot(id, p))); if (overflow.length > 0) { playersOverflow.style.display = 'flex'; playersOverflowBtn.textContent = '+' + overflow.length; overflow.forEach(([id, p]) => { const row = document.createElement('div'); row.className = 'popover-player'; row.innerHTML = `${sanitizeForDOM(p.name.split(' ').map(w=>w[0]).join('').slice(0,2).toUpperCase())}${sanitizeForDOM(p.name)}`; playersPopover.appendChild(row); }); } else { playersOverflow.style.display = 'none'; } } // ── Find an open spot for a new card (no overlap with existing cards) ──── function findOpenSpot(startX, startY) { const size = VIRTUAL_CARD_SIZE; // cards placed right next to each other const existing = Object.values(cards); // Viewport bounds in virtual coords — keep the full card + controls visible const vw = viewport.clientWidth; const vh = viewport.clientHeight; const controlsExtra = 40; // room for drag/remove buttons beneath card const margin = 10; const minVX = pToV((-boardOffX + margin) / boardScale); const maxVX = pToV((-boardOffX + vw - margin) / boardScale) - VIRTUAL_CARD_SIZE; const minVY = pToV((-boardOffY + margin) / boardScale); const maxVY = pToV((-boardOffY + vh - margin) / boardScale) - VIRTUAL_CARD_SIZE - pToV(controlsExtra); function inViewport(x, y) { return x >= minVX && x <= maxVX && y >= minVY && y <= maxVY; } function overlaps(x, y) { return existing.some(c => Math.abs(c.x - x) < size && Math.abs(c.y - y) < size); } // Try the starting position first if (!overlaps(startX, startY) && inViewport(startX, startY)) return { x: startX, y: startY }; // Spiral outward in increments of card size until we find open space in viewport for (let ring = 1; ring <= 10; ring++) { const step = VIRTUAL_CARD_SIZE * 1.15; const dist = ring * step; // Try 8 directions per ring, with a small random offset for variety for (let i = 0; i < 8; i++) { const angle = (i / 8) * Math.PI * 2 + (Math.random() - 0.5) * 0.3; const tx = startX + Math.cos(angle) * dist; const ty = startY + Math.sin(angle) * dist; if (!overlaps(tx, ty) && inViewport(tx, ty)) return { x: tx, y: ty }; } } // Second pass: relax viewport constraint, just avoid overlaps if (!overlaps(startX, startY)) return { x: startX, y: startY }; for (let ring = 1; ring <= 10; ring++) { const step = VIRTUAL_CARD_SIZE * 1.15; const dist = ring * step; for (let i = 0; i < 8; i++) { const angle = (i / 8) * Math.PI * 2 + (Math.random() - 0.5) * 0.3; const tx = startX + Math.cos(angle) * dist; const ty = startY + Math.sin(angle) * dist; if (!overlaps(tx, ty)) return { x: tx, y: ty }; } } // Fallback: random scatter (very unlikely — would need 80+ cards clustered together) return { x: startX + (Math.random() - 0.5) * 500, y: startY + (Math.random() - 0.5) * 500 }; } // ── Draw ────────────────────────────────────────────────────────────────── function drawCard(type) { if (!socket || !roomId) return; let cardKey; if (type === 'shapes') { if (!shapesShuffled.length) shapesShuffled = shuffle([...SHAPES_CARDS]); cardKey = shapesShuffled.shift(); } else if (type === 'pattern') { if (!patternShuffled.length) patternShuffled = shuffle([...PATTERN_CARDS]); cardKey = patternShuffled.shift(); } else { if (!movementShuffled.length) movementShuffled = shuffle([...MOVEMENT_CARDS]); cardKey = movementShuffled.shift(); } const cardId = 'card-' + Date.now() + '-' + Math.random().toString(36).slice(2, 7); const vw = viewport.clientWidth; const vh = viewport.clientHeight; // Compute centre of viewport in virtual coords, then find open spot const centerVX = pToV((-boardOffX + vw / 2) / boardScale); const centerVY = pToV((-boardOffY + vh / 2) / boardScale); const { x, y } = findOpenSpot(centerVX - VIRTUAL_CARD_SIZE / 2, centerVY - VIRTUAL_CARD_SIZE / 2); socket.emit('drawCard', { cardId, type, cardKey, x, y }); } function drawSpecificCard(type, cardKey) { if (!socket || !roomId) return; const cardId = 'card-' + Date.now() + '-' + Math.random().toString(36).slice(2, 7); const vw = viewport.clientWidth; const vh = viewport.clientHeight; const centerVX = pToV((-boardOffX + vw / 2) / boardScale); const centerVY = pToV((-boardOffY + vh / 2) / boardScale); const { x, y } = findOpenSpot(centerVX - VIRTUAL_CARD_SIZE / 2, centerVY - VIRTUAL_CARD_SIZE / 2); socket.emit('drawCard', { cardId, type, cardKey, x, y }); } // ── Question history & highlighting ────────────────────────────────────────── function renderQuestionHistory() { if (!questionHistoryList) return; // Clear existing entries (preserve empty state div) Array.from(questionHistoryList.querySelectorAll('.q-history-entry')).forEach(el => el.remove()); // Count unclaimed cards in history (drawn with no question) const claimedIds = new Set(questions.flatMap(q => q.cardIds || [])); const unclaimedCount = Object.keys(cardHistory).filter(id => !claimedIds.has(id)).length; const hasAnything = questions.length > 0 || unclaimedCount > 0; // Show/hide Clear All Questions button const caqBtn = document.getElementById('clear-all-questions-btn'); if (caqBtn) caqBtn.style.display = questions.length > 0 ? '' : 'none'; if (!hasAnything) { questionHistoryEmpty.style.display = ''; return; } questionHistoryEmpty.style.display = 'none'; // (no question) entry — shown first if there are unclaimed cards if (unclaimedCount > 0) { const entry = document.createElement('div'); entry.className = 'q-history-entry q-history-noquestion'; entry.dataset.qid = '__none__'; const header = document.createElement('div'); header.className = 'q-history-header'; header.innerHTML = `(no question)`; entry.appendChild(header); const countEl = document.createElement('span'); countEl.className = 'q-card-count'; countEl.textContent = unclaimedCount + (unclaimedCount === 1 ? ' card' : ' cards'); countEl.style.color = 'rgba(150,145,140,0.7)'; entry.appendChild(countEl); entry.addEventListener('click', () => { const claimedIds2 = new Set(questions.flatMap(qu => qu.cardIds || [])); const unclaimedIds = Object.keys(cardHistory).filter(id => !claimedIds2.has(id)); openQuestionCardsOverlay('(no question)', 'rgba(150,145,140,0.7)', unclaimedIds); }); questionHistoryList.appendChild(entry); } questions.forEach(q => { const entry = document.createElement('div'); entry.className = 'q-history-entry'; entry.dataset.qid = q.id; // Color swatch + text const header = document.createElement('div'); header.className = 'q-history-header'; header.innerHTML = `${sanitizeForDOM(q.text)}`; entry.appendChild(header); // Card count const count = q.cardIds ? q.cardIds.filter(id => cardHistory[id]).length : 0; if (count > 0) { const countEl = document.createElement('span'); countEl.className = 'q-card-count'; countEl.textContent = count + (count === 1 ? ' card' : ' cards'); countEl.style.color = q.color; entry.appendChild(countEl); } entry.addEventListener('click', () => { openQuestionCardsOverlay(q.text, q.color, q.cardIds || []); }); questionHistoryList.appendChild(entry); }); } function setHighlight(qId) { highlightedQuestionId = qId; applyHighlight(); renderQuestionHistory(); } function clearHighlight() { highlightedQuestionId = null; applyHighlight(); renderQuestionHistory(); } function applyHighlight() { const isNone = highlightedQuestionId === '__none__'; const q = (!isNone && highlightedQuestionId) ? questions.find(q => q.id === highlightedQuestionId) : null; const claimedIds = new Set(questions.flatMap(qu => qu.cardIds || [])); Object.values(cards).forEach(card => { const el = document.getElementById('cw-' + card.id); if (!el) return; if (!highlightedQuestionId) { el.removeAttribute('data-q-highlight'); el.style.removeProperty('--q-color'); } else if (isNone) { // Highlight unclaimed cards const on = !claimedIds.has(card.id); el.setAttribute('data-q-highlight', on ? 'on' : 'off'); if (on) el.style.setProperty('--q-color', 'rgba(150,145,140,0.7)'); else el.style.removeProperty('--q-color'); } else if (q) { const on = q.cardIds.includes(card.id); el.setAttribute('data-q-highlight', on ? 'on' : 'off'); if (on) el.style.setProperty('--q-color', q.color); else el.style.removeProperty('--q-color'); } }); } // ── Question cards overlay ────────────────────────────────────────────── let _qCardsOverlayCardIds = []; // remember which cards are shown so we can return from flip function openQuestionCardsOverlay(questionText, color, cardIds) { _qCardsOverlayCardIds = cardIds; questionCardsHeader.innerHTML = `${sanitizeForDOM(questionText)}`; questionCardsGrid.innerHTML = ''; const validIds = cardIds.filter(id => cardHistory[id]); if (validIds.length === 0) { questionCardsGrid.innerHTML = '
No cards drawn for this question.
'; } else { validIds.forEach(id => { const c = cardHistory[id]; const thumb = document.createElement('div'); thumb.className = 'q-card-thumb'; thumb.innerHTML = ``; thumb.addEventListener('click', () => { openFlipOverlayWithReturn(c.type, c.cardKey); }); questionCardsGrid.appendChild(thumb); }); } questionCardsOverlay.classList.add('visible'); } function closeQuestionCardsOverlay() { questionCardsOverlay.classList.remove('visible'); questionCardsGrid.innerHTML = ''; _qCardsOverlayCardIds = []; } // Open flip overlay but return to question cards overlay when closed function openFlipOverlayWithReturn(type, key) { questionCardsOverlay.classList.remove('visible'); if (type === 'pattern') { // Pattern cards have no card-back content — show front image large const src = escapeAttr(getFrontSrc(type, key)); flipOverlayContent.style.transition = ''; flipOverlayContent.style.opacity = '0'; flipOverlayContent.innerHTML = `
`; flipOverlay.classList.add('visible'); document.body.classList.add('on-flip'); requestAnimationFrame(() => requestAnimationFrame(() => { flipOverlayContent.style.transition = 'opacity 0.2s'; flipOverlayContent.style.opacity = '1'; })); } else { openFlipOverlay(type, key); } _flipReturnToQuestionCards = true; } let _flipReturnToQuestionCards = false; questionCardsOverlay.addEventListener('click', e => { if (e.target === questionCardsOverlay) closeQuestionCardsOverlay(); }); questionCardsClose.addEventListener('click', closeQuestionCardsOverlay); // ── Pattern picker ────────────────────────────────────────────────────── const patternPickerOverlay = document.getElementById('pattern-picker-overlay'); const patternPickerPanel = document.getElementById('pattern-picker-panel'); const patternPickerClose = document.getElementById('pattern-picker-close'); const patternPickerRandom = document.getElementById('pattern-picker-random'); const patternPickerGrid = document.getElementById('pattern-picker-grid'); function openPatternPicker() { patternPickerGrid.innerHTML = ''; PATTERN_CARDS.forEach(key => { if (key === 'questions') return; // "how to" — not a play pattern const card = document.createElement('div'); card.className = 'pattern-picker-card'; card.innerHTML = `${sanitizeForDOM(key.replace(/-/g, ' '))}`; card.addEventListener('click', () => { closePatternPicker(); drawSpecificCard('pattern', key); }); patternPickerGrid.appendChild(card); }); patternPickerOverlay.classList.add('visible'); } function closePatternPicker() { patternPickerOverlay.classList.remove('visible'); patternPickerGrid.innerHTML = ''; } patternPickerOverlay.addEventListener('click', e => { if (e.target === patternPickerOverlay) closePatternPicker(); }); patternPickerClose.addEventListener('click', closePatternPicker); patternPickerRandom.addEventListener('click', () => { closePatternPicker(); drawCard('pattern'); }); function clearAllCards() { if (!socket || !roomId) return; if (Object.keys(cards).length === 0) return; socket.emit('clearAllCards'); } // ── Tidy: arrange all cards in a grid ───────────────────────────────────── function tidyCards() { const cardList = Object.values(cards); if (cardList.length === 0) return; const isMobile = window.innerWidth <= 640; const cols = isMobile ? 3 : Math.min(cardList.length, 6); const gap = VIRTUAL_CARD_SIZE * 0.25; const cellSize = VIRTUAL_CARD_SIZE + gap; // Sort cards by draw order (card IDs contain timestamps) cardList.sort((a, b) => (a.id > b.id ? 1 : -1)); // Compute grid starting position so it's centered around (0,0) in virtual coords const rows = Math.ceil(cardList.length / cols); const gridW = cols * cellSize - gap; const gridH = rows * cellSize - gap; const startX = -gridW / 2; const startY = -gridH / 2; cardList.forEach((card, i) => { const col = i % cols; const row = Math.floor(i / cols); const newX = startX + col * cellSize; const newY = startY + row * cellSize; // Update local state card.x = newX; card.y = newY; // Move the DOM element const el = document.getElementById('cw-' + card.id); if (el) { el.style.left = vToP(newX) + 'px'; el.style.top = vToP(newY) + 'px'; } // Sync to server/other players socket.emit('moveCard', { cardId: card.id, x: newX, y: newY }); }); // Pan & zoom to show the full grid const vw = viewport.clientWidth; const vh = viewport.clientHeight; const controlsExtra = 40; const totalGridPxW = vToP(gridW + VIRTUAL_CARD_SIZE * 0.3); const totalGridPxH = vToP(gridH + VIRTUAL_CARD_SIZE * 0.3) + controlsExtra; if (isMobile) { // Zoom so 3 cards fit across the viewport width const targetW = vToP((3 * cellSize - gap) + VIRTUAL_CARD_SIZE * 0.3); boardScale = Math.min(1, (vw - 20) / targetW); } else { boardScale = Math.min(1, (vw - 40) / totalGridPxW, (vh - 40) / totalGridPxH); } // Center the grid in the viewport const centerBoardX = vToP(0); // grid is centered at (0,0) const centerBoardY = vToP(0); boardOffX = vw / 2 - centerBoardX * boardScale; boardOffY = vh / 2 - centerBoardY * boardScale; applyBoardTransform(); } // ── Render a sticky note ────────────────────────────────────────────────────── // ── Notes drawer ───────────────────────────────────────────────────────── const notesDrawer = document.getElementById('notes-drawer'); const notesDrawerHandle = document.getElementById('notes-drawer-handle'); const notesDrawerBody = document.getElementById('notes-drawer-body'); const notesMessages = document.getElementById('notes-messages'); const notesInput = document.getElementById('notes-input'); const notesSendBtn = document.getElementById('notes-send-btn'); const notesDrawerOverlay = document.getElementById('notes-drawer-overlay'); function toggleNotesDrawer() { notesDrawer.classList.toggle('open'); notesDrawerOverlay.classList.toggle('active'); if (notesDrawer.classList.contains('open')) { notesMessages.scrollTop = notesMessages.scrollHeight; } } function closeNotesDrawer() { notesDrawer.classList.remove('open'); notesDrawerOverlay.classList.remove('active'); } notesDrawerHandle.addEventListener('click', toggleNotesDrawer); notesDrawerOverlay.addEventListener('click', closeNotesDrawer); function renderNoteMessage(note) { const isMe = note.authorId === myId; const msg = document.createElement('div'); msg.className = 'notes-msg' + (isMe ? ' notes-msg-mine' : ''); msg.innerHTML = `${sanitizeForDOM(note.author)}${sanitizeForDOM(note.text)}`; notesMessages.appendChild(msg); notesMessages.scrollTop = notesMessages.scrollHeight; } function renderAllNotes() { notesMessages.innerHTML = ''; notes.forEach(n => renderNoteMessage(n)); } function sendNote() { const text = notesInput.value.trim(); if (!text || !socket || !roomId) return; socket.emit('sendNote', { text }); notesInput.value = ''; notesInput.style.height = 'auto'; } function autoGrowNotesInput() { notesInput.style.height = 'auto'; notesInput.style.height = Math.min(notesInput.scrollHeight, 120) + 'px'; } notesInput.addEventListener('input', autoGrowNotesInput); notesInput.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendNote(); } }); notesSendBtn.addEventListener('click', sendNote); // ── Render a card ───────────────────────────────────────────────────────── function renderCard(cardData, isNew) { const id = sanitizeCardId(cardData.id); if (!id || document.getElementById('cw-' + id)) return; const frontSrc = escapeAttr(getFrontSrc(cardData.type, cardData.cardKey)); const rotation = cardData.rotation || 0; const wrapper = document.createElement('div'); wrapper.id = 'cw-' + id; wrapper.className = 'card-wrapper' + (isNew ? ' new-card' : ''); wrapper.style.left = vToP(cardData.x) + 'px'; wrapper.style.top = vToP(cardData.y) + 'px'; // Accessible label on wrapper — screen readers announce this; browsers never flash it const cardLabel = cardData.type.charAt(0).toUpperCase() + cardData.type.slice(1) + ' card: ' + String(cardData.cardKey).replace(/-/g, ' '); wrapper.setAttribute('role', 'img'); wrapper.setAttribute('aria-label', cardLabel); wrapper.innerHTML = `
${cardData.type === 'shapes' ? '' : ''}
`; // Click on card image → flip (show card back in overlay) const imgHolder = wrapper.querySelector('.card-img-holder'); const img = imgHolder.querySelector('img'); const revealCard = () => { wrapper.classList.add('img-loaded'); if (isNew) { wrapper.classList.add('new-card'); setTimeout(() => wrapper.classList.remove('new-card'), 350); } }; if (img.complete) { revealCard(); } else { img.addEventListener('load', revealCard, { once: true }); img.addEventListener('error', revealCard, { once: true }); } imgHolder.addEventListener('click', e => { e.stopPropagation(); openFlipOverlay(cardData.type, cardData.cardKey); }); // Drag — via the drag button (not the card image) const dragBtn = wrapper.querySelector('.drag-btn'); dragBtn.addEventListener('mousedown', e => startDrag(e, id, wrapper)); dragBtn.addEventListener('touchstart', e => startDrag(e, id, wrapper), { passive: false }); // Rotate — 90° increments, synced to all players (shapes only) const rotateBtn = wrapper.querySelector('.rotate-btn'); if (rotateBtn) rotateBtn.addEventListener('click', e => { e.stopPropagation(); socket.emit('rotateCard', { cardId: id }); }); // Remove wrapper.querySelector('.remove-btn').addEventListener('click', e => { e.stopPropagation(); socket.emit('removeCard', { cardId: id }); }); board.appendChild(wrapper); if (boardHint) boardHint.style.display = 'none'; } function closeFlipOverlay() { flipOverlay.classList.remove('visible'); document.body.classList.remove('on-flip'); flipOverlayContent.innerHTML = ''; closeDirectionPanel(); // Return to question cards overlay if we came from there if (_flipReturnToQuestionCards && _qCardsOverlayCardIds.length) { _flipReturnToQuestionCards = false; questionCardsOverlay.classList.add('visible'); } else { _flipReturnToQuestionCards = false; } } function openDirectionPanel(dirName) { const backPath = `card-backs/directions/${dirName}-back.html`; fetch(backPath) .then(res => { if (!res.ok) throw new Error('Not found'); return res.text(); }) .then(html => { const safe = html.replace(/)<[^<]*)*<\/script>/gi, ''); flipDirectionContent.innerHTML = safe; flipDirectionPanel.classList.remove('hidden'); }) .catch(() => { flipDirectionContent.innerHTML = '
Direction card not found.
'; flipDirectionPanel.classList.remove('hidden'); }); } function closeDirectionPanel() { flipDirectionPanel.classList.add('hidden'); flipDirectionContent.innerHTML = ''; } // ── Drag ────────────────────────────────────────────────────────────────── let zCounter = 10; function nextZ() { return ++zCounter; } function startDrag(e, cardId, wrapper) { e.preventDefault(); e.stopPropagation(); const pt = getEventPoint(e); dragging = { cardId, wrapper, startX: pt.x, startY: pt.y, origX: cards[cardId] ? cards[cardId].x : 0, origY: cards[cardId] ? cards[cardId].y : 0, _lastEmit: 0, }; wrapper.style.zIndex = nextZ(); } document.addEventListener('mousemove', e => { if (dragging) handleDragMove(e.clientX, e.clientY); }); document.addEventListener('mouseup', () => endDrag()); document.addEventListener('touchmove', e => { if (dragging) { e.preventDefault(); const t = e.touches[0]; handleDragMove(t.clientX, t.clientY); } }, { passive: false }); document.addEventListener('touchend', () => endDrag()); // ── Card dock ─────────────────────────────────────────────────────────── const cardDock = document.getElementById('card-dock'); const dockCards = document.getElementById('dock-cards'); const dockHint = document.getElementById('dock-hint'); const dockedIds = new Set(); // card IDs currently in dock function isDockVisible() { return cardDock && window.innerWidth > 640; } function isOverDock(cy) { if (!isDockVisible()) return false; const dockRect = cardDock.getBoundingClientRect(); return cy >= dockRect.top; } function updateDockHint() { if (dockHint) dockHint.style.display = dockedIds.size > 0 ? 'none' : ''; } // Visual-only: add card to dock DOM (no socket emit) function dockCardVisual(cardId) { if (dockedIds.has(cardId)) return; const card = cards[cardId]; if (!card) return; dockedIds.add(cardId); // Hide from board const boardEl = document.getElementById('cw-' + cardId); if (boardEl) boardEl.style.display = 'none'; // Create dock thumbnail const thumb = document.createElement('div'); thumb.className = 'dock-card'; thumb.id = 'dock-' + cardId; thumb.innerHTML = ''; // Drag out of dock let dockDragging = null; function startDockDrag(ev) { ev.preventDefault(); ev.stopPropagation(); const pt = ev.touches ? { x: ev.touches[0].clientX, y: ev.touches[0].clientY } : { x: ev.clientX, y: ev.clientY }; dockDragging = { startX: pt.x, startY: pt.y, moved: false }; } function moveDockDrag(ev) { if (!dockDragging) return; const pt = ev.touches ? { x: ev.touches[0].clientX, y: ev.touches[0].clientY } : { x: ev.clientX, y: ev.clientY }; if (Math.abs(pt.y - dockDragging.startY) > 15) dockDragging.moved = true; if (dockDragging.moved && !isOverDock(pt.y)) { undockCard(cardId, pt.x, pt.y); dockDragging = null; } } function endDockDrag() { if (dockDragging && !dockDragging.moved) { openFlipOverlay(card.type, card.cardKey); } dockDragging = null; } thumb.addEventListener('mousedown', startDockDrag); thumb.addEventListener('touchstart', startDockDrag, { passive: false }); document.addEventListener('mousemove', moveDockDrag); document.addEventListener('touchmove', moveDockDrag, { passive: false }); document.addEventListener('mouseup', endDockDrag); document.addEventListener('touchend', endDockDrag); thumb._dockCleanup = () => { document.removeEventListener('mousemove', moveDockDrag); document.removeEventListener('touchmove', moveDockDrag); document.removeEventListener('mouseup', endDockDrag); document.removeEventListener('touchend', endDockDrag); }; dockCards.appendChild(thumb); updateDockHint(); } // Visual-only: remove card from dock DOM (no socket emit) function undockCardVisual(cardId) { dockedIds.delete(cardId); const thumbEl = document.getElementById('dock-' + cardId); if (thumbEl) { if (thumbEl._dockCleanup) thumbEl._dockCleanup(); thumbEl.remove(); } const boardEl = document.getElementById('cw-' + cardId); if (boardEl) boardEl.style.display = ''; updateDockHint(); } // Dock a card and notify server function dockCard(cardId) { dockCardVisual(cardId); socket.emit('dockCard', { cardId }); } // Undock a card, position it on board, and notify server function undockCard(cardId, screenX, screenY) { undockCardVisual(cardId); const boardEl = document.getElementById('cw-' + cardId); if (boardEl) { const vpRect = viewport.getBoundingClientRect(); const dockRect = cardDock.getBoundingClientRect(); const cardH = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--card-h')) || 200; const placeY = dockRect.top - 80 - cardH; const px = (screenX - vpRect.left - boardOffX) / boardScale; const py = (placeY - vpRect.top - boardOffY) / boardScale; const vx = pToV(px); const vy = pToV(py); boardEl.style.left = px + 'px'; boardEl.style.top = py + 'px'; boardEl.style.zIndex = nextZ(); if (cards[cardId]) { cards[cardId].x = vx; cards[cardId].y = vy; } socket.emit('undockCard', { cardId, x: vx, y: vy }); } } function handleDragMove(cx, cy) { const { cardId, wrapper, startX, startY, origX, origY } = dragging; const newPX = vToP(origX) + (cx - startX) / boardScale; const newPY = vToP(origY) + (cy - startY) / boardScale; const newX = pToV(newPX); const newY = pToV(newPY); wrapper.style.left = newPX + 'px'; wrapper.style.top = newPY + 'px'; // Dock highlight if (isDockVisible()) { cardDock.classList.toggle('drag-over', isOverDock(cy)); } const now = Date.now(); if (now - dragging._lastEmit > 50) { dragging._lastEmit = now; if (cards[cardId]) { cards[cardId].x = newX; cards[cardId].y = newY; } socket.emit('moveCard', { cardId, x: newX, y: newY }); } } function endDrag() { if (!dragging) return; const { cardId, wrapper } = dragging; if (cardDock) cardDock.classList.remove('drag-over'); // Check if dropped over dock const wrapRect = wrapper.getBoundingClientRect(); const cardBottomY = wrapRect.bottom; if (isDockVisible() && isOverDock(cardBottomY)) { dockCard(cardId); dragging = null; return; } const x = pToV(parseFloat(wrapper.style.left)); const y = pToV(parseFloat(wrapper.style.top)); if (cards[cardId]) { cards[cardId].x = x; cards[cardId].y = y; } socket.emit('moveCard', { cardId, x, y }); dragging = null; } function getEventPoint(e) { return e.touches ? { x: e.touches[0].clientX, y: e.touches[0].clientY } : { x: e.clientX, y: e.clientY }; } // ── Pan & Zoom ──────────────────────────────────────────────────────────── // ── Pan & zoom ─────────────────────────────────────────────────────────── let panning = null; // Desktop: mouse pan viewport.addEventListener('mousedown', e => { if (e.target !== viewport && e.target !== board) return; panning = { startX: e.clientX - boardOffX, startY: e.clientY - boardOffY }; }); viewport.addEventListener('mousemove', e => { if (!panning) return; boardOffX = e.clientX - panning.startX; boardOffY = e.clientY - panning.startY; applyBoardTransform(); }); viewport.addEventListener('mouseup', () => { panning = null; }); viewport.addEventListener('mouseleave', () => { panning = null; }); // Desktop: scroll wheel zoom viewport.addEventListener('wheel', e => { e.preventDefault(); const factor = e.deltaY < 0 ? 1.025 : 0.975; const rect = viewport.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; const oldScale = boardScale; boardScale = Math.max(0.2, Math.min(3, boardScale * factor)); boardOffX = mx - (mx - boardOffX) * (boardScale / oldScale); boardOffY = my - (my - boardOffY) * (boardScale / oldScale); applyBoardTransform(); }, { passive: false }); // Mobile: single-finger pan + two-finger pinch zoom // Track up to 2 touches by identifier so we handle them correctly let touch1 = null, touch2 = null; let pinchStartDist = 0, pinchStartScale = 1; let pinchStartMidX = 0, pinchStartMidY = 0; let pinchStartOffX = 0, pinchStartOffY = 0; function getTouchById(list, id) { for (let t of list) if (t.identifier === id) return t; return null; } function dist(a, b) { return Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY); } viewport.addEventListener('touchstart', e => { // Only start pan/pinch when touching background (not cards/notes) const onBackground = e.target === viewport || e.target === board; if (e.touches.length === 1 && onBackground) { e.preventDefault(); touch1 = { id: e.touches[0].identifier, x: e.touches[0].clientX, y: e.touches[0].clientY }; touch2 = null; panning = { startX: e.touches[0].clientX - boardOffX, startY: e.touches[0].clientY - boardOffY }; } else if (e.touches.length === 2) { e.preventDefault(); panning = null; const t1 = e.touches[0], t2 = e.touches[1]; touch1 = { id: t1.identifier }; touch2 = { id: t2.identifier }; pinchStartDist = dist(t1, t2); pinchStartScale = boardScale; pinchStartMidX = (t1.clientX + t2.clientX) / 2; pinchStartMidY = (t1.clientY + t2.clientY) / 2; pinchStartOffX = boardOffX; pinchStartOffY = boardOffY; } }, { passive: false }); viewport.addEventListener('touchmove', e => { e.preventDefault(); if (e.touches.length === 1 && panning) { const t = e.touches[0]; boardOffX = t.clientX - panning.startX; boardOffY = t.clientY - panning.startY; applyBoardTransform(); } else if (e.touches.length === 2 && touch2 !== null) { const t1 = e.touches[0], t2 = e.touches[1]; const d = dist(t1, t2); const newScale = Math.max(0.2, Math.min(3, pinchStartScale * d / pinchStartDist)); const midX = (t1.clientX + t2.clientX) / 2; const midY = (t1.clientY + t2.clientY) / 2; const rect = viewport.getBoundingClientRect(); const mx = pinchStartMidX - rect.left; const my = pinchStartMidY - rect.top; // Offset = original offset, adjusted for scale change around pinch midpoint, plus finger translation boardOffX = pinchStartOffX - mx * (newScale - pinchStartScale) + (midX - pinchStartMidX); boardOffY = pinchStartOffY - my * (newScale - pinchStartScale) + (midY - pinchStartMidY); boardScale = newScale; applyBoardTransform(); } }, { passive: false }); viewport.addEventListener('touchend', e => { if (e.touches.length === 0) { panning = null; touch1 = null; touch2 = null; } if (e.touches.length === 1) { // One finger lifted during pinch — switch to single-finger pan touch2 = null; panning = { startX: e.touches[0].clientX - boardOffX, startY: e.touches[0].clientY - boardOffY }; } }); function applyBoardTransform() { board.style.transform = `translate(${boardOffX}px,${boardOffY}px) scale(${boardScale})`; } // Smoothly pan so a card (virtual coords) is centered in the viewport function panToCard(vx, vy) { const vw = viewport.clientWidth; const vh = viewport.clientHeight; const cardPx = vToP(VIRTUAL_CARD_SIZE); // Target: card center is at viewport center const targetOffX = vw / 2 - (vToP(vx) + cardPx / 2) * boardScale; const targetOffY = vh / 2 - (vToP(vy) + cardPx / 2) * boardScale; const startX = boardOffX, startY = boardOffY; const dx = targetOffX - startX, dy = targetOffY - startY; // Skip animation if already close enough if (Math.abs(dx) < 2 && Math.abs(dy) < 2) return; const duration = 300; const t0 = performance.now(); function step(now) { const p = Math.min(1, (now - t0) / duration); const ease = 1 - Math.pow(1 - p, 3); // ease-out cubic boardOffX = startX + dx * ease; boardOffY = startY + dy * ease; applyBoardTransform(); if (p < 1) requestAnimationFrame(step); } requestAnimationFrame(step); } // ── Session persistence (localStorage) ────────────────────────────────────── function saveSession() { if (!roomId) return; try { localStorage.setItem('emergent_session', JSON.stringify({ roomId, myName: myName, questions, activeQuestionId, cards: Object.values(cards), cardHistory: Object.values(cardHistory), notes: notes, savedAt: Date.now(), sessionActive: true })); } catch(e) {} } function clearSavedSession() { try { localStorage.removeItem('emergent_session'); } catch(e) {} } function updateCardCount() { if (!cardCountEl) return; const n = Object.keys(cards).length; cardCountEl.textContent = n === 1 ? '1 card' : n + ' cards'; if (boardHint) boardHint.style.display = n > 0 ? 'none' : 'flex'; } // ── Card Library ────────────────────────────────────────────────────────── const libraryOverlay = document.getElementById('library-overlay'); const libraryClose = document.getElementById('library-close'); const libraryGrid = document.getElementById('library-grid'); const libraryGridView = document.getElementById('library-grid-view'); const libraryDetail = document.getElementById('library-detail'); const libraryBackBtn = document.getElementById('library-back-btn'); const libraryDetailFront = document.getElementById('library-detail-front'); const libraryDetailContent = document.getElementById('library-detail-content'); const libraryAddBtn = document.getElementById('library-add-btn'); let currentLibraryTab = 'shapes'; let currentDetailSide = 'front'; let currentDetailKey = null; let currentDetailType = null; document.getElementById('open-library-btn').addEventListener('click', openLibrary); libraryClose.addEventListener('click', closeLibrary); libraryOverlay.addEventListener('click', e => { if (e.target === libraryOverlay) closeLibrary(); }); libraryBackBtn.addEventListener('click', showLibraryGrid); libraryAddBtn.addEventListener('click', () => { if (!currentDetailType || !currentDetailKey) return; drawSpecificCard(currentDetailType, currentDetailKey); closeLibrary(); }); // Tab switching document.querySelectorAll('.library-tab').forEach(tab => { tab.addEventListener('click', () => { document.querySelectorAll('.library-tab').forEach(t => t.classList.remove('active')); tab.classList.add('active'); currentLibraryTab = tab.dataset.tab; showLibraryGrid(); renderLibraryGrid(currentLibraryTab); }); }); // (detail side tabs removed — back/meaning shown automatically) function openLibrary() { libraryOverlay.classList.add('visible'); showLibraryGrid(); renderLibraryGrid(currentLibraryTab); } function closeLibrary() { libraryOverlay.classList.remove('visible'); } function showLibraryGrid() { libraryGridView.classList.remove('hidden'); libraryDetail.classList.remove('visible'); } function showLibraryDetail(type, key) { currentDetailType = type; currentDetailKey = key; currentDetailSide = 'back'; // Update back label — always "More" const backLabel = document.getElementById('library-detail-back-label'); if (backLabel) backLabel.textContent = 'More'; libraryGridView.classList.add('hidden'); libraryDetail.classList.add('visible'); // For pattern cards show the BACK image in the front slot; others show front const displaySrc = type === 'pattern' ? `img/patterns/pattern_${key}_BACK.png` : getFrontSrc(type, key); const label = type === 'shapes' ? 'Shape ' + key : type === 'pattern' ? (PATTERN_LABELS[key] || key.replace(/-/g, ' ')) : key.replace(/-/g, ' '); libraryDetailFront.innerHTML = `${sanitizeForDOM(label)}`; renderDetailSide(type, key, 'back'); } function renderDetailSide(type, key, side) { if (side === 'front') { libraryDetailContent.innerHTML = ''; return; } const backPath = getBackPath(type, key); // Pattern "more" — show the front image if (type === 'pattern') { const frontSrc = getFrontSrc(type, key); libraryDetailContent.innerHTML = `
${sanitizeForDOM(key.replace(/-/g,' '))}
`; return; } // Load back HTML for shapes/movement libraryDetailContent.innerHTML = '
Loading…
'; fetch(backPath) .then(res => { if (!res.ok) throw new Error('Not found'); return res.text(); }) .then(html => { const safe = html.replace(/)<[^<]*)*<\/script>/gi, ''); libraryDetailContent.innerHTML = safe; injectConsiderLabel(libraryDetailContent); }) .catch(() => { libraryDetailContent.innerHTML = '
No back content found.
Expected: ' + escapeAttr(backPath) + '
'; }); } function renderLibraryGrid(type) { libraryGrid.innerHTML = ''; const keys = type === 'shapes' ? SHAPES_CARDS : type === 'pattern' ? PATTERN_CARDS : MOVEMENT_CARDS; keys.forEach(key => { const src = getFrontSrc(type, key); const label = type === 'shapes' ? '#' + key : type === 'pattern' ? (PATTERN_LABELS[key] || key.replace(/-/g, ' ')) : key.replace(/-/g, ' '); const item = document.createElement('div'); item.className = 'library-card'; item.innerHTML = ` ${sanitizeForDOM(label)}
${sanitizeForDOM(label)}
`; item.addEventListener('click', () => showLibraryDetail(type, key)); libraryGrid.appendChild(item); }); } // ── Chat mode ────────────────────────────────────────────────────────────── (function() { const CHAT_TOTAL_ROWS = 30; const CHAT_SHAPES_CARDS = Array.from({length: 64}, (_, i) => String(i + 1)); const CHAT_MOVEMENT_CARDS = [ 'weward','betweenward','straightforward','inward','outward','upward', 'downward','homeward','backward','awkward','sideward','inchward', 'toward','onward','wayward','here' ]; function chatShuffle(arr) { const a = [...arr]; for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } let chatShapesShuffled = chatShuffle(CHAT_SHAPES_CARDS); let chatMovementShuffled = chatShuffle(CHAT_MOVEMENT_CARDS); function chatDrawRandomCard() { if (Math.random() < 0.6) { if (!chatShapesShuffled.length) chatShapesShuffled = chatShuffle(CHAT_SHAPES_CARDS); return { type: 'shapes', key: chatShapesShuffled.shift() }; } else { if (!chatMovementShuffled.length) chatMovementShuffled = chatShuffle(CHAT_MOVEMENT_CARDS); return { type: 'movement', key: chatMovementShuffled.shift() }; } } function chatGetFrontSrc(type, key) { if (type === 'shapes') return `img/shapes/${key}.png`; if (type === 'movement') return `img/directions/direction-cards-${key}.png`; return ''; } function chatGetBackPath(type, key) { if (type === 'shapes') return `card-backs/shapes/${key}-back.html`; if (type === 'movement') return `card-backs/directions/${key}-back.html`; return ''; } function injectChatConsiderLabel(container) { const dir = container.querySelector('.directions'); if (!dir) return; const dirName = Array.from(dir.classList).find(c => c !== 'directions'); if (!dirName) return; if (dir.querySelector('.consider-label')) return; const label = document.createElement('span'); label.className = 'consider-label'; label.innerHTML = 'CONSIDER ' + dirName.toUpperCase() + ''; dir.insertBefore(label, dir.firstChild); } // ── Chat card history (for summary) ─────────────────────────────────────── // Each entry: { text, type, key, num } let chatRows = []; let chatActiveIndex = 0; const chatJournal = document.getElementById('chat-journal'); const chatPanel = document.getElementById('chat-panel'); function buildChatCard(type, key) { const wrap = document.createElement('div'); wrap.className = 'chat-card'; wrap.dataset.type = type; wrap.dataset.key = key; const imgWrap = document.createElement('div'); imgWrap.className = 'chat-card-img'; imgWrap.style.cursor = 'pointer'; imgWrap.addEventListener('click', () => { openFlipOverlay(type, key); }); const img = document.createElement('img'); img.src = chatGetFrontSrc(type, key); img.alt = type + ' card'; imgWrap.appendChild(img); wrap.appendChild(imgWrap); return wrap; } function addChatRow(index) { const row = document.createElement('div'); row.className = 'row'; const inputWrap = document.createElement('div'); inputWrap.className = 'row-input-wrap'; const num = document.createElement('span'); num.className = 'row-num'; num.textContent = index + 1; const input = document.createElement('textarea'); input.rows = 1; input.className = 'row-input'; input.setAttribute('autocomplete', 'off'); input.setAttribute('autocorrect', 'on'); input.setAttribute('autocapitalize', 'sentences'); input.setAttribute('spellcheck', 'true'); if (index === 0) input.placeholder = 'Start by saying hi'; function autoResize() { input.style.height = 'auto'; input.style.height = input.scrollHeight + 'px'; } input.addEventListener('input', autoResize); const sendBtn = document.createElement('button'); sendBtn.className = 'row-send-btn'; sendBtn.setAttribute('aria-label', 'Send'); sendBtn.innerHTML = ''; inputWrap.appendChild(num); inputWrap.appendChild(input); inputWrap.appendChild(sendBtn); const cardSlot = document.createElement('div'); cardSlot.className = 'row-card'; row.appendChild(inputWrap); row.appendChild(cardSlot); chatJournal.appendChild(row); const entry = { input, cardSlot, row }; chatRows.push(entry); function submitChatRow() { if (!input.value.trim() || cardSlot.classList.contains('visible')) return; input.readOnly = true; sendBtn.style.display = 'none'; if (document.activeElement === input) input.blur(); if (window.visualViewport && window.visualViewport.scale > 1) { const vv = window.visualViewport; window.scrollTo(vv.offsetLeft, vv.offsetTop); } const { type, key } = chatDrawRandomCard(); cardSlot.appendChild(buildChatCard(type, key)); cardSlot.classList.add('visible'); if (index + 1 < CHAT_TOTAL_ROWS) { addChatRow(index + 1); chatActiveIndex = index + 1; const next = chatRows[chatActiveIndex]; next.input.focus(); setTimeout(() => { cardSlot.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); next.input.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, 50); } else { setTimeout(() => cardSlot.scrollIntoView({ behavior: 'smooth', block: 'nearest' }), 50); } } input.addEventListener('keydown', function(e) { if (e.key !== 'Enter' || e.shiftKey) return; e.preventDefault(); submitChatRow(); }); sendBtn.addEventListener('click', submitChatRow); return entry; } // ── Mode toggle ──────────────────────────────────────────────────────────── const modeSandboxBtn = document.getElementById('mode-sandbox-btn'); const modeChatBtn = document.getElementById('mode-chat-btn'); const mainEl = document.getElementById('main'); const questionBarEl = document.getElementById('question-bar'); const chatActionsEl = document.getElementById('chat-topbar-actions'); let chatMode = false; let chatInitialized = false; function setMode(mode) { chatMode = (mode === 'chat'); modeSandboxBtn.classList.toggle('active', !chatMode); modeSandboxBtn.setAttribute('aria-pressed', String(!chatMode)); modeChatBtn.classList.toggle('active', chatMode); modeChatBtn.setAttribute('aria-pressed', String(chatMode)); mainEl.style.display = chatMode ? 'none' : ''; chatPanel.style.display = chatMode ? 'flex' : 'none'; questionBarEl.style.display = chatMode ? 'none' : ''; chatActionsEl.style.display = chatMode ? '' : 'none'; document.body.classList.toggle('on-chat', chatMode); if (chatMode && !chatInitialized) { chatInitialized = true; const firstRow = addChatRow(0); // Small delay so the panel is visible before focusing setTimeout(() => firstRow.input.focus(), 80); } else if (chatMode) { // Re-focus active row setTimeout(() => { const active = chatRows[chatActiveIndex]; if (active && !active.input.readOnly) active.input.focus(); }, 80); } try { localStorage.setItem('emergent_mode', mode); } catch(e) {} } modeSandboxBtn.addEventListener('click', () => setMode('sandbox')); modeChatBtn.addEventListener('click', () => setMode('chat')); // ── Chat save summary ────────────────────────────────────────────────────── async function buildAndOpenChatSummary(summaryWin) { const date = new Date().toLocaleDateString('en-US', { year:'numeric', month:'long', day:'numeric' }); const entries = []; chatRows.forEach(({ input, cardSlot }, idx) => { const text = input.value.trim(); const cardEl = cardSlot.querySelector('.chat-card'); if (!text && !cardEl) return; const type = cardEl ? cardEl.dataset.type : null; const key = cardEl ? cardEl.dataset.key : null; entries.push({ text, type, key, num: idx + 1 }); }); const backContents = {}; await Promise.all(entries.map(async e => { if (!e.type || !e.key) return; try { const r = await fetch(chatGetBackPath(e.type, e.key)); if (r.ok) backContents[e.num] = await r.text(); } catch(err) {} })); function esc(s) { return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } function sanitizeText(s) { const d = document.createElement('div'); d.textContent = String(s); return d.innerHTML; } const entryRows = entries.map(e => { const frontSrc = (e.type && e.key) ? window.location.origin + '/' + chatGetFrontSrc(e.type, e.key) : null; const backHtml = (backContents[e.num] || '').replace(/)<[^<]*)*<\/script>/gi, ''); const cardBlock = frontSrc ? `
${esc(e.type)} card
${backHtml}
` : ''; return `
${e.num}
${e.text ? `
${sanitizeText(e.text)}
` : ''} ${cardBlock}
`; }).join(''); const html = ` Emergent Chat — ${esc(date)}

Emergent

${esc(date)}  ·  Chat session
${entryRows}
<${'script'}> document.querySelectorAll('.directions').forEach(function(dir) { var dirName = Array.from(dir.classList).find(function(c) { return c !== 'directions'; }); if (!dirName) return; var label = document.createElement('span'); label.className = 'consider-label'; label.innerHTML = 'CONSIDER ' + dirName.toUpperCase() + ''; dir.insertBefore(label, dir.firstChild); }); `; if (summaryWin && !summaryWin.closed) { summaryWin.document.open(); summaryWin.document.write(html); summaryWin.document.close(); summaryWin.focus(); } } const chatSaveBtn = document.getElementById('chat-save-btn'); const chatClearBtn = document.getElementById('chat-clear-btn'); if (chatSaveBtn) chatSaveBtn.addEventListener('click', () => { const summaryWin = window.open('', '_blank'); if (summaryWin) summaryWin.document.write('

Building summary…

'); buildAndOpenChatSummary(summaryWin).catch(err => console.error('[chat-summary]', err)); }); if (chatClearBtn) chatClearBtn.addEventListener('click', () => { chatShapesShuffled = chatShuffle(CHAT_SHAPES_CARDS); chatMovementShuffled = chatShuffle(CHAT_MOVEMENT_CARDS); chatRows.forEach(({ row }) => row.remove()); chatRows = []; chatActiveIndex = 0; chatInitialized = false; // Re-initialize chatInitialized = true; const firstRow = addChatRow(0); setTimeout(() => firstRow.input.focus(), 30); chatPanel.scrollTo({ top: 0, behavior: 'smooth' }); }); // Restore mode preference try { const savedMode = localStorage.getItem('emergent_mode'); if (savedMode === 'chat') setMode('chat'); } catch(e) {} // Expose openFlipOverlay for chat cards (uses the shared flip overlay) // The main flip overlay handler is already wired — this just calls it window._chatMode = { setMode }; })(); // end chat mode IIFE // ── Custom cursor ───────────────────────────────────────────────────────── (function() { const dot = document.getElementById('cursor-dot'); const ring = document.getElementById('cursor-ring'); if (!dot || !ring) return; if (window.matchMedia('(hover: none)').matches) return; const SIZE = 120; const cx2 = SIZE / 2; const ctx = ring.getContext('2d'); let mx = -200, my = -200; let rx = -200, ry = -200; let t = 0; let cursorReady = false; dot.style.opacity = '0'; ring.style.opacity = '0'; function noise(t, seed) { return Math.sin(t * 1.7 + seed) * 0.4 + Math.sin(t * 3.1 + seed * 2.3) * 0.25 + Math.sin(t * 5.3 + seed * 0.7) * 0.15; } function drawRing(r, strokeStyle) { ctx.clearRect(0, 0, SIZE, SIZE); ctx.beginPath(); ctx.arc(cx2, cx2, r, 0, Math.PI * 2); ctx.strokeStyle = strokeStyle; ctx.lineWidth = 1; ctx.stroke(); } document.addEventListener('mousemove', e => { mx = e.clientX; my = e.clientY; dot.style.transform = `translate(${mx}px, ${my}px)`; if (!cursorReady) { cursorReady = true; rx = mx; ry = my; setTimeout(() => { dot.style.opacity = ''; ring.style.opacity = ''; }, 60); } }); // Bright rainbow cycle const DOT_COLORS = [ [255, 220, 60], // yellow [255, 150, 50], // orange [255, 80, 80], // red [255, 80, 180], // pink [180, 80, 255], // purple [ 80, 140, 255], // blue [ 60, 220, 200], // cyan [100, 220, 80], // green ]; function lerpColor(a, b, p) { return [ Math.round(a[0] + (b[0] - a[0]) * p), Math.round(a[1] + (b[1] - a[1]) * p), Math.round(a[2] + (b[2] - a[2]) * p), ]; } function tick() { t += 0.025; rx += (mx - rx) * 0.18; ry += (my - ry) * 0.18; const speed = Math.hypot(mx - rx, my - ry); const jitterAmt = Math.min(speed * 0.05, 1.0); const jx = noise(t, 0) * jitterAmt; const jy = noise(t, 3) * jitterAmt; ring.style.transform = `translate(${rx + jx - cx2}px, ${ry + jy - cx2}px)`; const cl = ring.classList; // Heartbeat const bpm = 56; const cycle = (t * 0.025 * bpm / 60) % 1; function gauss(x, center, width) { const d = x - center; return Math.exp(-(d * d) / (2 * width * width)); } const beat = gauss(cycle, 0.10, 0.045) * 3.5 + gauss(cycle, 0.22, 0.055) * 2.0 + gauss(cycle, 0.88, 0.045) * 3.5 + gauss(cycle, 1.00, 0.055) * 2.0; const baseR = 28 + beat + noise(t, 6) * 0.4; // Dot color cycle — slow loop through gradient palette const colorT = (t * 0.012) % 1; // full loop every ~8s const colorPos = colorT * DOT_COLORS.length; const colorIdx = Math.floor(colorPos) % DOT_COLORS.length; const colorFrac = colorPos - Math.floor(colorPos); const nextIdx = (colorIdx + 1) % DOT_COLORS.length; const [r, g, b] = lerpColor(DOT_COLORS[colorIdx], DOT_COLORS[nextIdx], colorFrac); const onFlip = document.body.classList.contains('on-flip'); const onModal = document.body.classList.contains('on-modal'); const bl = document.body.classList; const isDark = bl.contains('dark-mode'); const isLightApp = bl.contains('light-mode') && !bl.contains('on-lobby'); const isLightLobby = bl.contains('light-mode') && bl.contains('on-lobby'); const onTopbar = cl.contains('on-topbar'); const onCard = cl.contains('on-card'); // Set dot color — cycling always, hidden over cards if (!cl.contains('i-bar')) { if (onCard || onFlip || onModal) { dot.style.opacity = '0'; } else { dot.style.opacity = ''; dot.style.background = `rgb(${r},${g},${b})`; } } if (cl.contains('i-bar')) { ctx.clearRect(0, 0, SIZE, SIZE); } else if (onCard || onFlip || onModal) { // Filled translucent circle using cycling color ctx.clearRect(0, 0, SIZE, SIZE); ctx.beginPath(); ctx.arc(cx2, cx2, baseR, 0, Math.PI * 2); ctx.fillStyle = `rgba(${r},${g},${b},0.18)`; ctx.fill(); ctx.strokeStyle = `rgba(${r},${g},${b},0.5)`; ctx.lineWidth = 1; ctx.stroke(); } else { const stroke = (isLightApp && !onTopbar) || isLightLobby ? 'rgba(30,25,20,0.28)' : 'rgba(255,255,255,0.35)'; drawRing(baseR, stroke); } requestAnimationFrame(tick); } tick(); document.addEventListener('mouseover', e => { const onCard = !!e.target.closest('.card-img-holder') && !e.target.closest('.card-controls'); const onText = !!e.target.closest('input[type="text"], input:not([type]), textarea'); const onTopbar = !!e.target.closest('#topbar-wrap'); if (onCard) { dot.classList.add('on-card'); ring.classList.add('on-card'); } if (onText) { dot.classList.add('i-bar'); ring.classList.add('i-bar'); } else { dot.classList.remove('i-bar'); ring.classList.remove('i-bar'); } if (onTopbar) { dot.classList.add('on-topbar'); ring.classList.add('on-topbar'); } }); document.addEventListener('mouseout', e => { const onCard = !!e.target.closest('.card-img-holder') && !e.target.closest('.card-controls'); const onTopbar = !!e.target.closest('#topbar-wrap'); if (onCard) { dot.classList.remove('on-card'); ring.classList.remove('on-card'); } if (onTopbar) { dot.classList.remove('on-topbar'); ring.classList.remove('on-topbar'); } }); document.addEventListener('mouseenter', () => { dot.style.opacity = ''; ring.style.opacity = ''; }); })(); })();