diff --git a/src/dashboard/templates/base.html b/src/dashboard/templates/base.html index d20b1fe0..600fd623 100644 --- a/src/dashboard/templates/base.html +++ b/src/dashboard/templates/base.html @@ -395,7 +395,7 @@ if (!dot || !label) return; if (!wsConnected) { dot.className = 'mc-conn-dot red'; - label.textContent = 'OFFLINE'; + label.textContent = 'Reconnecting...'; } else if (ollamaOk === false) { dot.className = 'mc-conn-dot amber'; label.textContent = 'NO LLM'; @@ -431,7 +431,12 @@ var ws; try { ws = new WebSocket(protocol + '//' + window.location.host + '/swarm/live'); - } catch(e) { return; } + } catch(e) { + // WebSocket constructor failed (e.g. invalid environment) — retry + setTimeout(connectStatusWs, reconnectDelay); + reconnectDelay = Math.min(reconnectDelay * 2, 30000); + return; + } ws.onopen = function() { wsConnected = true; diff --git a/static/world/index.html b/static/world/index.html index a001bcf1..a5c86d8b 100644 --- a/static/world/index.html +++ b/static/world/index.html @@ -103,6 +103,28 @@
+ + + + + +
@@ -157,6 +179,38 @@ import { StateReader } from "./state.js"; import { messageQueue } from "./queue.js"; + // --- Mobile detection: redirect to text interface on small touch devices --- + const isMobile = window.matchMedia("(pointer: coarse)").matches + && window.innerWidth < 768; + if (isMobile) { + const fallback = document.getElementById("webgl-fallback"); + if (fallback) fallback.classList.remove("hidden"); + // Don't initialise the 3D scene on mobile + throw new Error("Mobile device — 3D scene skipped"); + } + + // --- WebGL support detection --- + function _hasWebGL() { + try { + const canvas = document.createElement("canvas"); + return !!( + canvas.getContext("webgl2") || + canvas.getContext("webgl") || + canvas.getContext("experimental-webgl") + ); + } catch { + return false; + } + } + if (!_hasWebGL()) { + const fallback = document.getElementById("webgl-fallback"); + const detail = document.getElementById("fallback-detail"); + if (fallback) fallback.classList.remove("hidden"); + if (detail) detail.textContent = + "Your device doesn\u2019t support WebGL. Use a modern browser to see the 3D workshop."; + throw new Error("WebGL not supported"); + } + // --- Renderer --- const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); @@ -167,6 +221,26 @@ renderer.toneMappingExposure = 0.8; document.body.prepend(renderer.domElement); + // --- WebGL context loss / restore --- + const _gpuOverlay = document.getElementById("gpu-error-overlay"); + const _gpuRetryMsg = document.getElementById("gpu-retry-msg"); + let _animating = true; + + renderer.domElement.addEventListener("webglcontextlost", (ev) => { + ev.preventDefault(); + _animating = false; + if (_gpuOverlay) _gpuOverlay.classList.remove("hidden"); + if (_gpuRetryMsg) _gpuRetryMsg.textContent = "Reconnecting automatically\u2026"; + console.warn("[Workshop] WebGL context lost — waiting for restore"); + }, false); + + renderer.domElement.addEventListener("webglcontextrestored", () => { + _animating = true; + if (_gpuOverlay) _gpuOverlay.classList.add("hidden"); + console.info("[Workshop] WebGL context restored — resuming"); + animate(); + }, false); + // --- Scene --- const scene = new THREE.Scene(); scene.background = new THREE.Color(0x0a0a14); @@ -195,6 +269,13 @@ if (moodEl) { moodEl.textContent = state.timmyState.mood; } + // Keep fallback view in sync when it's visible + const fallbackMood = document.getElementById("fallback-mood"); + const fallbackDetail = document.getElementById("fallback-detail"); + if (fallbackMood) fallbackMood.textContent = state.timmyState.mood; + if (fallbackDetail && state.timmyState.activity) { + fallbackDetail.textContent = state.timmyState.activity + "\u2026"; + } }); // Replay queued jobs whenever the server comes back online. @@ -537,6 +618,7 @@ const clock = new THREE.Clock(); function animate() { + if (!_animating) return; requestAnimationFrame(animate); const dt = clock.getDelta(); diff --git a/static/world/style.css b/static/world/style.css index a9d85431..3bef1fcf 100644 --- a/static/world/style.css +++ b/static/world/style.css @@ -715,3 +715,116 @@ canvas { width: 100%; } } + +/* GPU context loss overlay */ +#gpu-error-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(10, 10, 20, 0.95); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 200; + text-align: center; + padding: 32px; +} + +#gpu-error-overlay.hidden { + display: none; +} + +.gpu-error-icon { + font-size: 48px; + margin-bottom: 16px; + opacity: 0.8; +} + +.gpu-error-title { + font-size: 20px; + color: #daa520; + margin-bottom: 12px; + font-weight: bold; +} + +.gpu-error-msg { + font-size: 14px; + color: #aaa; + line-height: 1.6; + max-width: 400px; + margin-bottom: 24px; +} + +.gpu-error-retry { + font-size: 12px; + color: #666; + margin-top: 8px; +} + +/* WebGL / mobile fallback — text-only mode */ +#webgl-fallback { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: #0a0a14; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 200; + text-align: center; + padding: 32px; +} + +#webgl-fallback.hidden { + display: none; +} + +.fallback-title { + font-size: 22px; + color: #daa520; + margin-bottom: 8px; + font-weight: bold; +} + +.fallback-subtitle { + font-size: 13px; + color: #666; + margin-bottom: 32px; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.fallback-status { + font-size: 15px; + color: #aaa; + line-height: 1.7; + max-width: 400px; + margin-bottom: 32px; +} + +.fallback-status .mood-line { + color: #daa520; + font-style: italic; +} + +.fallback-link { + display: inline-block; + padding: 10px 24px; + border: 1px solid rgba(218, 165, 32, 0.4); + border-radius: 6px; + color: #daa520; + font-size: 13px; + text-decoration: none; + transition: all 0.2s ease; +} + +.fallback-link:hover { + background: rgba(218, 165, 32, 0.1); + border-color: rgba(218, 165, 32, 0.7); +}