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 @@
+
+
+
🔮
+
Timmy is taking a quick break
+
+ The 3D workshop lost its connection to the GPU.
+ Attempting to restore the scene…
+
+
Reconnecting automatically…
+
+
+
+
+
@@ -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);
+}