fix: QA sprint v1 — 7 issues resolved

Fixes:
- #22 OrbitControls damping: call updateControls() in animate loop
- #23 Empty catch blocks: add console.warn + error surfacing to chat panel
- #24 escapeHtml: add quote escaping (" '), use in renderAgentList
- #25 WS reconnect: check close code (1000/1001) before reconnecting,
  add exponential backoff + heartbeat zombie detection
- #26 IDLE state visibility: brighten from near-invisible to #005500
- #5 PWA: manifest.json, service worker (network-first), theme-color,
  favicon, loading screen, safe-area-inset padding, apple-mobile-web-app
- #14 Adaptive render quality: new quality.js hardware detection (low/
  medium/high tiers), tiered particle counts, grid density, antialias,
  pixel ratio caps

New files:
- js/quality.js — hardware detection + quality tier logic
- manifest.json — PWA manifest
- public/sw.js — service worker (network-first with offline cache)
- public/favicon.svg — SVG favicon
- icons/icon-192.svg, icons/icon-512.svg — PWA icons
This commit is contained in:
2026-03-19 00:14:27 +00:00
parent fe2d9a31e3
commit 916acde69c
12 changed files with 343 additions and 36 deletions

4
icons/icon-192.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">
<rect width="192" height="192" fill="#000"/>
<text x="96" y="138.24" text-anchor="middle" font-family="Courier New, monospace" font-weight="bold" font-size="115.19999999999999" fill="#00ff41" style="text-shadow: 0 0 9.600000000000001px #00ff41">M</text>
</svg>

After

Width:  |  Height:  |  Size: 352 B

4
icons/icon-512.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#000"/>
<text x="256" y="368.64" text-anchor="middle" font-family="Courier New, monospace" font-weight="bold" font-size="307.2" fill="#00ff41" style="text-shadow: 0 0 25.6px #00ff41">M</text>
</svg>

After

Width:  |  Height:  |  Size: 327 B

View File

@@ -2,12 +2,34 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="3D visualization of the Timmy agent network" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<meta name="apple-mobile-web-app-title" content="Tower World" />
<link rel="manifest" href="/manifest.json" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/icons/icon-192.svg" />
<title>Timmy Tower World</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #000; overflow: hidden; font-family: 'Courier New', monospace; }
canvas { display: block; }
/* Loading screen — hidden by main.js after init */
#loading-screen {
position: fixed; inset: 0; z-index: 100;
display: flex; align-items: center; justify-content: center;
background: #000;
color: #00ff41; font-size: 14px; letter-spacing: 4px;
text-shadow: 0 0 12px #00ff41;
font-family: 'Courier New', monospace;
}
#loading-screen.hidden { display: none; }
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
#loading-screen span { animation: blink 1.2s ease-in-out infinite; }
#ui-overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none; z-index: 10;
@@ -39,9 +61,18 @@
font-size: 11px; color: #555;
}
#connection-status.connected { color: #00ff41; text-shadow: 0 0 6px #00ff41; }
/* Safe area padding for notched devices */
@supports (padding: env(safe-area-inset-top)) {
#hud { top: calc(16px + env(safe-area-inset-top)); left: calc(16px + env(safe-area-inset-left)); }
#status-panel { top: calc(16px + env(safe-area-inset-top)); right: calc(16px + env(safe-area-inset-right)); }
#chat-panel { bottom: calc(16px + env(safe-area-inset-bottom)); left: calc(16px + env(safe-area-inset-left)); right: calc(16px + env(safe-area-inset-right)); }
#connection-status { bottom: calc(16px + env(safe-area-inset-bottom)); right: calc(16px + env(safe-area-inset-right)); }
}
</style>
</head>
<body>
<div id="loading-screen"><span>INITIALIZING...</span></div>
<div id="ui-overlay">
<div id="hud">
<h1>TIMMY TOWER WORLD</h1>
@@ -56,5 +87,11 @@
<div id="connection-status">OFFLINE</div>
</div>
<script type="module" src="./js/main.js"></script>
<script>
// Register service worker for PWA / offline support
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}
</script>
</body>
</html>

31
js/effects.js vendored
View File

@@ -1,22 +1,27 @@
import * as THREE from 'three';
import { getQualityTier } from './quality.js';
let rainParticles;
let rainPositions;
let rainVelocities;
const RAIN_COUNT = 2000;
let rainCount = 0;
export function initEffects(scene) {
initMatrixRain(scene);
initStarfield(scene);
const tier = getQualityTier();
initMatrixRain(scene, tier);
initStarfield(scene, tier);
}
function initMatrixRain(scene) {
const geo = new THREE.BufferGeometry();
const positions = new Float32Array(RAIN_COUNT * 3);
const velocities = new Float32Array(RAIN_COUNT);
const colors = new Float32Array(RAIN_COUNT * 3);
function initMatrixRain(scene, tier) {
// Scale particle count by quality tier
rainCount = tier === 'low' ? 500 : tier === 'medium' ? 1200 : 2000;
for (let i = 0; i < RAIN_COUNT; i++) {
const geo = new THREE.BufferGeometry();
const positions = new Float32Array(rainCount * 3);
const velocities = new Float32Array(rainCount);
const colors = new Float32Array(rainCount * 3);
for (let i = 0; i < rainCount; i++) {
positions[i * 3] = (Math.random() - 0.5) * 100;
positions[i * 3 + 1] = Math.random() * 50 + 5;
positions[i * 3 + 2] = (Math.random() - 0.5) * 100;
@@ -34,7 +39,7 @@ function initMatrixRain(scene) {
rainVelocities = velocities;
const mat = new THREE.PointsMaterial({
size: 0.12,
size: tier === 'low' ? 0.16 : 0.12,
vertexColors: true,
transparent: true,
opacity: 0.7,
@@ -45,8 +50,8 @@ function initMatrixRain(scene) {
scene.add(rainParticles);
}
function initStarfield(scene) {
const count = 500;
function initStarfield(scene, tier) {
const count = tier === 'low' ? 150 : tier === 'medium' ? 350 : 500;
const geo = new THREE.BufferGeometry();
const positions = new Float32Array(count * 3);
@@ -72,7 +77,7 @@ function initStarfield(scene) {
export function updateEffects(_time) {
if (!rainParticles) return;
for (let i = 0; i < RAIN_COUNT; i++) {
for (let i = 0; i < rainCount; i++) {
rainPositions[i * 3 + 1] -= rainVelocities[i];
if (rainPositions[i * 3 + 1] < -1) {
rainPositions[i * 3 + 1] = 40 + Math.random() * 20;

View File

@@ -2,7 +2,7 @@ import { initWorld, onWindowResize } from './world.js';
import { initAgents, updateAgents, getAgentCount } from './agents.js';
import { initEffects, updateEffects } from './effects.js';
import { initUI, updateUI } from './ui.js';
import { initInteraction } from './interaction.js';
import { initInteraction, updateControls } from './interaction.js';
import { initWebSocket, getConnectionState, getJobCount } from './websocket.js';
let frameCount = 0;
@@ -20,6 +20,10 @@ function main() {
window.addEventListener('resize', () => onWindowResize(camera, renderer));
// Dismiss loading screen
const loadingScreen = document.getElementById('loading-screen');
if (loadingScreen) loadingScreen.classList.add('hidden');
function animate() {
requestAnimationFrame(animate);
@@ -31,6 +35,7 @@ function main() {
lastFpsTime = now;
}
updateControls();
updateEffects(now);
updateAgents(now);
updateUI({

90
js/quality.js Normal file
View File

@@ -0,0 +1,90 @@
/**
* quality.js — Detect hardware capability and return a quality tier.
*
* Tiers:
* 'low' — older iPads, phones, low-end GPUs (reduce particles, simpler effects)
* 'medium' — mid-range (moderate particle count)
* 'high' — desktop, modern iPad Pro (full quality)
*
* Detection uses a combination of:
* - Device pixel ratio (low DPR = likely low-end)
* - Logical core count (navigator.hardwareConcurrency)
* - Device memory (navigator.deviceMemory, Chrome/Edge only)
* - Screen size (small viewport = likely mobile)
* - Touch capability (touch + small screen = phone/tablet)
* - WebGL renderer string (if available)
*/
let cachedTier = null;
export function getQualityTier() {
if (cachedTier) return cachedTier;
let score = 0;
// Core count: 1-2 = low, 4 = mid, 8+ = high
const cores = navigator.hardwareConcurrency || 2;
if (cores >= 8) score += 3;
else if (cores >= 4) score += 2;
else score += 0;
// Device memory (Chrome/Edge): < 4GB = low, 4-8 = mid, 8+ = high
const mem = navigator.deviceMemory || 4;
if (mem >= 8) score += 3;
else if (mem >= 4) score += 2;
else score += 0;
// Screen dimensions (logical pixels)
const maxDim = Math.max(window.screen.width, window.screen.height);
if (maxDim < 768) score -= 1; // phone
else if (maxDim >= 1920) score += 1; // large desktop
// DPR: high DPR on small screens = more GPU work
const dpr = window.devicePixelRatio || 1;
if (dpr > 2 && maxDim < 1024) score -= 1; // retina phone
// Touch-only device heuristic
const touchOnly = 'ontouchstart' in window && !window.matchMedia('(pointer: fine)').matches;
if (touchOnly) score -= 1;
// Try reading WebGL renderer for GPU hints
try {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl') || canvas.getContext('webgl2');
if (gl) {
const debugExt = gl.getExtension('WEBGL_debug_renderer_info');
if (debugExt) {
const renderer = gl.getParameter(debugExt.UNMASKED_RENDERER_WEBGL).toLowerCase();
// Known low-end GPU strings
if (renderer.includes('swiftshader') || renderer.includes('llvmpipe') || renderer.includes('software')) {
score -= 3; // software renderer
}
if (renderer.includes('apple gpu') || renderer.includes('apple m')) {
score += 2; // Apple Silicon is good
}
}
gl.getExtension('WEBGL_lose_context')?.loseContext();
}
} catch {
// Can't probe GPU, use other signals
}
// Map score to tier
if (score <= 1) cachedTier = 'low';
else if (score <= 4) cachedTier = 'medium';
else cachedTier = 'high';
console.info(`[Matrix Quality] Tier: ${cachedTier} (score: ${score}, cores: ${cores}, mem: ${mem}GB, dpr: ${dpr}, touch: ${touchOnly})`);
return cachedTier;
}
/**
* Get the recommended pixel ratio cap for the renderer.
*/
export function getMaxPixelRatio() {
const tier = getQualityTier();
if (tier === 'low') return 1;
if (tier === 'medium') return 1.5;
return 2;
}

View File

@@ -11,6 +11,9 @@ const $chatPanel = document.getElementById('chat-panel');
const MAX_CHAT_ENTRIES = 12;
const chatEntries = [];
const IDLE_COLOR = '#005500';
const ACTIVE_COLOR = '#00ff41';
export function initUI() {
renderAgentList();
}
@@ -18,12 +21,14 @@ export function initUI() {
function renderAgentList() {
const defs = getAgentDefs();
$agentList.innerHTML = defs.map(a => {
const css = colorToCss(a.color);
const css = escapeAttr(colorToCss(a.color));
const safeLabel = escapeHtml(a.label);
const safeId = escapeAttr(a.id);
return `<div class="agent-row">
<span class="label">[</span>
<span style="color:${css}">${a.label}</span>
<span style="color:${css}">${safeLabel}</span>
<span class="label">]</span>
<span id="agent-state-${a.id}" style="color:#003300"> IDLE</span>
<span id="agent-state-${safeId}" style="color:${IDLE_COLOR}"> IDLE</span>
</div>`;
}).join('');
}
@@ -49,7 +54,7 @@ export function updateUI({ fps, agentCount, jobCount, connectionState }) {
const el = document.getElementById(`agent-state-${a.id}`);
if (el) {
el.textContent = ` ${a.state.toUpperCase()}`;
el.style.color = a.state === 'active' ? '#00ff41' : '#003300';
el.style.color = a.state === 'active' ? ACTIVE_COLOR : IDLE_COLOR;
}
});
}
@@ -61,24 +66,42 @@ export function updateUI({ fps, agentCount, jobCount, connectionState }) {
* @param {string} cssColor — CSS color string, e.g. '#00ff88'
*/
export function appendChatMessage(agentLabel, message, cssColor) {
const color = cssColor || '#00ff41';
const color = escapeAttr(cssColor || '#00ff41');
const entry = document.createElement('div');
entry.className = 'chat-entry';
entry.innerHTML = `<span class="agent-name" style="color:${color}">${agentLabel}</span>: ${escapeHtml(message)}`;
entry.innerHTML = `<span class="agent-name" style="color:${color}">${escapeHtml(agentLabel)}</span>: ${escapeHtml(message)}`;
chatEntries.push(entry);
if (chatEntries.length > MAX_CHAT_ENTRIES) {
while (chatEntries.length > MAX_CHAT_ENTRIES) {
const removed = chatEntries.shift();
$chatPanel.removeChild(removed);
try { $chatPanel.removeChild(removed); } catch { /* already removed */ }
}
$chatPanel.appendChild(entry);
$chatPanel.scrollTop = $chatPanel.scrollHeight;
}
/**
* Escape HTML text content — prevents tag injection.
*/
function escapeHtml(str) {
return str
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* Escape a value for use inside an HTML attribute (style="...", id="...").
*/
function escapeAttr(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}

View File

@@ -10,7 +10,15 @@ let ws = null;
let connectionState = 'disconnected';
let jobCount = 0;
let reconnectTimer = null;
const RECONNECT_DELAY_MS = 5000;
let reconnectAttempts = 0;
const RECONNECT_BASE_MS = 2000;
const RECONNECT_MAX_MS = 30000;
const HEARTBEAT_INTERVAL_MS = 30000;
const HEARTBEAT_TIMEOUT_MS = 5000;
let heartbeatTimer = null;
let heartbeatTimeout = null;
export function initWebSocket(_scene) {
if (!WS_URL) {
@@ -30,7 +38,9 @@ function connect() {
try {
ws = new WebSocket(WS_URL);
} catch {
} catch (err) {
console.warn('[Matrix WS] Connection failed:', err.message || err);
logEvent('WebSocket connection failed');
connectionState = 'disconnected';
scheduleReconnect();
return;
@@ -38,7 +48,9 @@ function connect() {
ws.onopen = () => {
connectionState = 'connected';
reconnectAttempts = 0;
clearTimeout(reconnectTimer);
startHeartbeat();
ws.send(JSON.stringify({
type: 'subscribe',
channel: 'agents',
@@ -47,27 +59,75 @@ function connect() {
};
ws.onmessage = (event) => {
// Any message counts as a heartbeat response
resetHeartbeatTimeout();
try {
handleMessage(JSON.parse(event.data));
} catch {
} catch (err) {
console.warn('[Matrix WS] Message parse/handle error:', err.message, '| raw:', event.data?.slice?.(0, 200));
}
};
ws.onerror = () => {
ws.onerror = (event) => {
console.warn('[Matrix WS] Error event:', event);
connectionState = 'disconnected';
};
ws.onclose = () => {
ws.onclose = (event) => {
connectionState = 'disconnected';
stopHeartbeat();
// Don't reconnect on clean close (1000) or going away (1001)
if (event.code === 1000 || event.code === 1001) {
console.info('[Matrix WS] Clean close (code ' + event.code + '), not reconnecting');
return;
}
console.warn('[Matrix WS] Unexpected close — code:', event.code, 'reason:', event.reason || '(none)');
scheduleReconnect();
};
}
function scheduleReconnect() {
clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(connect, RECONNECT_DELAY_MS);
// Exponential backoff: 2s, 4s, 8s, 16s, capped at 30s
const delay = Math.min(RECONNECT_BASE_MS * Math.pow(2, reconnectAttempts), RECONNECT_MAX_MS);
reconnectAttempts++;
console.info('[Matrix WS] Reconnecting in', Math.round(delay / 1000), 's (attempt', reconnectAttempts + ')');
reconnectTimer = setTimeout(connect, delay);
}
/* ── Heartbeat / zombie connection detection ── */
function startHeartbeat() {
stopHeartbeat();
heartbeatTimer = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
try {
ws.send(JSON.stringify({ type: 'ping' }));
} catch { /* ignore send failures, onclose will fire */ }
heartbeatTimeout = setTimeout(() => {
console.warn('[Matrix WS] Heartbeat timeout — closing zombie connection');
if (ws) ws.close(4000, 'heartbeat timeout');
}, HEARTBEAT_TIMEOUT_MS);
}
}, HEARTBEAT_INTERVAL_MS);
}
function stopHeartbeat() {
clearInterval(heartbeatTimer);
clearTimeout(heartbeatTimeout);
heartbeatTimer = null;
heartbeatTimeout = null;
}
function resetHeartbeatTimeout() {
clearTimeout(heartbeatTimeout);
heartbeatTimeout = null;
}
/* ── Message handler ── */
function handleMessage(msg) {
switch (msg.type) {
case 'agent_state': {
@@ -95,15 +155,17 @@ function handleMessage(msg) {
}
break;
}
case 'pong':
case 'agent_count':
break;
default:
console.debug('[Matrix WS] Unhandled message type:', msg.type);
break;
}
}
function logEvent(text) {
appendChatMessage('SYS', text, colorToCss(0x003300));
appendChatMessage('SYS', text, '#005500');
}
export function getConnectionState() {

View File

@@ -1,4 +1,5 @@
import * as THREE from 'three';
import { getMaxPixelRatio, getQualityTier } from './quality.js';
let scene, camera, renderer;
@@ -11,14 +12,15 @@ export function initWorld() {
camera.position.set(0, 12, 28);
camera.lookAt(0, 0, 0);
renderer = new THREE.WebGLRenderer({ antialias: true });
const tier = getQualityTier();
renderer = new THREE.WebGLRenderer({ antialias: tier !== 'low' });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setPixelRatio(Math.min(window.devicePixelRatio, getMaxPixelRatio()));
renderer.outputColorSpace = THREE.SRGBColorSpace;
document.body.prepend(renderer.domElement);
addLights(scene);
addGrid(scene);
addGrid(scene, tier);
return { scene, camera, renderer };
}
@@ -36,8 +38,9 @@ function addLights(scene) {
scene.add(fill);
}
function addGrid(scene) {
const grid = new THREE.GridHelper(100, 40, 0x003300, 0x001a00);
function addGrid(scene, tier) {
const gridDivisions = tier === 'low' ? 20 : 40;
const grid = new THREE.GridHelper(100, gridDivisions, 0x003300, 0x001a00);
grid.position.y = -0.01;
scene.add(grid);

22
manifest.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "Timmy Tower World",
"short_name": "Tower World",
"description": "3D visualization of the Timmy agent network",
"start_url": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#000000",
"theme_color": "#000000",
"icons": [
{
"src": "/icons/icon-192.svg",
"sizes": "192x192",
"type": "image/svg+xml"
},
{
"src": "/icons/icon-512.svg",
"sizes": "512x512",
"type": "image/svg+xml"
}
]
}

4
public/favicon.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<rect width="32" height="32" fill="#000"/>
<text x="16" y="23.04" text-anchor="middle" font-family="Courier New, monospace" font-weight="bold" font-size="19.2" fill="#00ff41" style="text-shadow: 0 0 1.6px #00ff41">M</text>
</svg>

After

Width:  |  Height:  |  Size: 317 B

48
public/sw.js Normal file
View File

@@ -0,0 +1,48 @@
/**
* Service Worker for Timmy Tower World PWA.
*
* Strategy: network-first with offline fallback.
* Caches the shell on install so the app works when iPad is offline.
*/
const CACHE_NAME = 'matrix-v1';
const SHELL_URLS = [
'/',
'/index.html',
'/manifest.json',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_URLS))
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
// Skip non-GET and WebSocket upgrade requests
if (event.request.method !== 'GET') return;
if (event.request.headers.get('upgrade') === 'websocket') return;
event.respondWith(
fetch(event.request)
.then((response) => {
// Cache successful responses
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
}
return response;
})
.catch(() => caches.match(event.request))
);
});