diff --git a/frontend/src/three/GhostCanvas.test.tsx b/frontend/src/three/GhostCanvas.test.tsx
new file mode 100644
index 0000000..08fa717
--- /dev/null
+++ b/frontend/src/three/GhostCanvas.test.tsx
@@ -0,0 +1,76 @@
+// The apparition must never take the séance down with it.
+//
+// This is a regression test for a real, observed failure: on a device where a
+// WebGL context cannot be created, `new THREE.WebGLRenderer()` throws, the
+// exception escapes the effect, and — with no error boundary above it — React
+// unmounts the ENTIRE root. The seeker got a blank page: no modes, no ask
+// field, no transcript. Verified in headless Chrome, where `#root` had zero
+// children and the console read "THREE.WebGLRenderer: Error creating WebGL
+// context".
+
+import { render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const construct = vi.fn()
+
+vi.mock('./GhostScene', () => ({
+ GhostScene: class {
+ constructor(canvas: HTMLCanvasElement) {
+ construct(canvas)
+ }
+ start() {}
+ dispose() {}
+ setVisual() {}
+ setMood() {}
+ setSpeakAmplitude() {}
+ flare() {}
+ },
+}))
+
+// Imported after the mock is registered.
+const { GhostCanvas } = await import('./GhostCanvas')
+
+function Seance() {
+ return (
+
+
+
+
+ )
+}
+
+describe('GhostCanvas', () => {
+ beforeEach(() => {
+ construct.mockReset()
+ construct.mockImplementation(() => {})
+ })
+
+ it('renders the canvas when the scene builds', () => {
+ const { container } = render()
+ expect(container.querySelector('canvas')).toBeTruthy()
+ expect(container.querySelector('.ghost-canvas-fallback')).toBeNull()
+ })
+
+ it('keeps the rest of the séance alive when WebGL is unavailable', () => {
+ construct.mockImplementation(() => {
+ throw new Error('THREE.WebGLRenderer: Error creating WebGL context.')
+ })
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+
+ // The assertion that matters: this render must not throw. Before the fix
+ // the error escaped the effect and tore down the whole tree.
+ const { container } = render()
+
+ // Everything a seeker actually needs is still on screen.
+ expect(screen.getByRole('button', { name: 'ask' })).toBeTruthy()
+ expect(container.querySelector('.ghost-canvas-fallback')).toBeTruthy()
+ expect(warn).toHaveBeenCalled()
+ warn.mockRestore()
+ })
+
+ it('does not show the fallback glow when the scene is fine', () => {
+ const { container } = render()
+ expect(container.querySelector('.ghost-canvas-fallback')).toBeNull()
+ expect(construct).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/frontend/src/three/GhostCanvas.tsx b/frontend/src/three/GhostCanvas.tsx
index f754bbe..a67b39c 100644
--- a/frontend/src/three/GhostCanvas.tsx
+++ b/frontend/src/three/GhostCanvas.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef } from 'react'
+import { useEffect, useRef, useState } from 'react'
import { GhostScene } from './GhostScene'
import type { GhostMood, GhostVisualState } from './GhostScene'
import { hauntBus } from '../lib/haunting'
@@ -18,12 +18,36 @@ export function GhostCanvas({ visual, mood, getAmplitude, className }: GhostCanv
const ampRef = useRef(getAmplitude)
ampRef.current = getAmplitude
+ // The apparition is atmosphere. It must never be able to take the séance
+ // down with it — and it could: `new GhostScene()` constructs a
+ // THREE.WebGLRenderer, which THROWS when a WebGL context cannot be created.
+ // An exception raised in an effect with no error boundary above it unmounts
+ // the entire React root, so a seeker on a device with no WebGL got a
+ // completely blank page instead of a séance. Not a missing ghost — nothing
+ // at all: no modes, no ask field, no transcript.
+ //
+ // Observed for real: headless Chrome with no GPU renders `#root` with zero
+ // children and the console reading "THREE.WebGLRenderer: Error creating
+ // WebGL context". The same happens on any device where the GPU is
+ // blocklisted, hardware acceleration is switched off, or the driver is too
+ // old — which is a real slice of phones, not a hypothetical.
+ const [failed, setFailed] = useState(false)
+
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
- const scene = new GhostScene(canvas)
+ let scene: GhostScene
+ try {
+ scene = new GhostScene(canvas)
+ scene.start()
+ } catch (err) {
+ // Deliberately swallowed, not rethrown: everything else on this page
+ // works perfectly well without a 3D apparition.
+ console.warn('[ghost] no apparition on this device; the séance continues', err)
+ setFailed(true)
+ return
+ }
sceneRef.current = scene
- scene.start()
const ampTimer = window.setInterval(() => {
scene.setSpeakAmplitude(ampRef.current?.() ?? 0)
}, 50)
@@ -47,7 +71,24 @@ export function GhostCanvas({ visual, mood, getAmplitude, className }: GhostCanv
return (
+ {/* The canvas element stays mounted even after a failure: the ref must
+ survive for a later remount to retry, and an empty canvas is
+ invisible anyway. The fallback simply sits behind it. */}
+ {failed && (
+
+ )}