From 9b1fe88e45db12f2bb0a8a8bbb23d3a3630b4f27 Mon Sep 17 00:00:00 2001 From: Dan Moore <9156191+drmrd@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:16:48 -0400 Subject: [PATCH 01/20] docs: plan the live pane WebGL 2 fallback --- .../2026-09-07-live-pane-webgl-fallback.md | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 docs/plans/2026-09-07-live-pane-webgl-fallback.md diff --git a/docs/plans/2026-09-07-live-pane-webgl-fallback.md b/docs/plans/2026-09-07-live-pane-webgl-fallback.md new file mode 100644 index 00000000..db321a3d --- /dev/null +++ b/docs/plans/2026-09-07-live-pane-webgl-fallback.md @@ -0,0 +1,76 @@ +# Live pane WebGL 2 fallback Implementation Plan + +> **For agentic workers:** Steps use checkbox (`- [ ]`) syntax for tracking. Every application change here runs the role-separated red-green-blue cycle: a failing `test:` commit, a minimal `feat:` commit, and a closing `refactor:` commit (empty when the review finds nothing). + +**Goal:** Stop telling WebGL 2 browsers that the 3D preview is unavailable. The preview mounts and renders through the renderer's own WebGL 2 fallback, and a dismissible notice near the pane says the preview is running without WebGPU. Issue #476. + +**Architecture:** Two gates refuse the live view today. `editor/shell/scene-pane.tsx` shows a design-system `EmptyState` reading "Your browser does not support WebGPU", and `bridge/react/scene-canvas.tsx` shows a bare `role="status"` message reading "This 3D view requires a WebGPU-capable browser". Both ask `detectRenderBackend()`, which returns `'webgpu'` when `navigator.gpu` is present and `'unsupported'` otherwise. Meanwhile `engine/renderer/create-renderer.ts` builds a `WebGPURenderer`, which selects WebGPU when it is there and falls back to its own WebGL 2 backend when it is not; the deterministic harness pins that same WebGL 2 backend with `forceWebGL` for every committed scene baseline. So the render path the gates refuse is the path CI already exercises. + +Neither gate can simply be deleted. Removing the bridge gate outright would mount the R3F `` in jsdom, where `react-use-measure` needs a `ResizeObserver` the unit environment does not define, so `editor-shell` and app tests would fail on a rendering environment they never meant to exercise. The gates need a wider question instead of no question: can this runtime render 3D at all. That is WebGPU, or a `webgl2` canvas context, and jsdom has neither. + +**Tech Stack:** React 19, `@react-three/fiber`, the existing `editor/design-system` `Banner` and `EmptyState` primitives, vitest with jsdom. + +**Spec:** Issue #476. Its precondition, live-view pixel coverage in CI, landed as issue #469 and ADR-0171. + +## Global Constraints + +- **Allowed files:** `editor/shell/scene-pane.tsx`, `editor/shell/scene-pane.css`, `editor/shell/scene-pane.test.tsx`, `bridge/react/live-preview-backend.ts` and its test, `bridge/react/scene-canvas.tsx` and its test, one export line in `bridge/index.ts`, this plan, and `docs/knowledge/decisions/ADR-0174-live-pane-webgl-fallback.md`. Anything under `engine/`, `core/`, `app/`, `storage/`, `e2e/`, or the build configuration means stop and report. +- **Repo rules:** Conventional Commits; no em-dashes; no trailers; author `Dan Moore <9156191+drmrd@users.noreply.github.com>`; ADR prose passes the humanizer standard. +- **Full check chain before the branch is done:** `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm test`, `pnpm build`, `pnpm rgb:audit`, each exit code read on its own. + +## Design decisions carried into this plan + +1. **The capability probe lives in `bridge/react/`, not in `engine/`.** Widening `RenderBackend` in `engine/renderer/detect-backend.ts` would be the natural home, and the ADR records that as the follow-up. This branch is fenced out of `engine/`, and the probe needs no Three.js: it asks `navigator.gpu` through the engine's existing `detectRenderBackend()` and then asks a throwaway canvas for a `webgl2` context. Bridge is the layer that decides whether the R3F canvas mounts, and it is the lowest layer both consumers can import from, since `editor/` may import `bridge/` but `bridge/` may not import `editor/`. +2. **`detectLivePreviewBackend()` returns three values, not two.** `'webgpu'`, `'webgl2'`, and `'unsupported'`. The pane needs all three: only `'unsupported'` still earns the empty state, and only `'webgl2'` raises the notice. +3. **The notice is the design system's `Banner`, rendered inline in the pane.** It is the shell's existing notice vocabulary, it already carries a dismiss control and the design-language styling, and rendering it directly rather than through the notification store keeps the message next to the pane it describes instead of in the app frame's banner row. The pane owns the dismissed flag as component state, so the notice appears once per session and stays gone after a dismissal. +4. **The notice overlays the pane rather than taking a layout row.** A layout row would change the pane's height and every measurement built on it. The overlay wrapper is pointer-inert so an orbit drag passes through it, and the banner itself takes pointer events back so the dismiss button works. This mirrors the existing `.scene-pane__overlay` treatment. +5. **No e2e file changes.** The notice and the widened gate only reach a runtime that lacks WebGPU. The `scene-*` specs run in the `scene-webgl` project, where `navigator.gpu` is present on the development Mac and the macOS CI runner, so those runs take the `'webgpu'` branch and render exactly what they render today. The `home.png` baseline is captured on the default view, which has no 3D region at all (`toggle-three-d.spec.ts` asserts the count is zero before the toggle). The chromium, firefox, and webkit projects now mount the live view where they previously showed the fallback text; the one spec that visits the pane there, `three-d-preview-canvas.spec.ts`, measures the region height and says in its own header that it holds either way. + +--- + +### Task 1: The probe + +**Files:** `bridge/react/live-preview-backend.ts`, `bridge/react/live-preview-backend.test.ts`. + +- [ ] **Step 1 (RED):** A failing test that `detectLivePreviewBackend()` reports `'webgpu'` with `navigator.gpu` present, `'webgl2'` with no `navigator.gpu` but an obtainable `webgl2` canvas context, and `'unsupported'` when neither is there. +- [ ] **Step 2 (GREEN):** The module. It delegates the WebGPU question to the engine's `detectRenderBackend()` and probes a detached canvas for the WebGL 2 context, treating a throw as no support. +- [ ] **Step 3 (BLUE):** Clean-code review and a closing `refactor:` commit. + +### Task 2: The bridge gate + +**Files:** `bridge/react/scene-canvas.tsx`, `bridge/react/scene-canvas.test.tsx`, `bridge/index.ts`. + +- [ ] **Step 1 (RED):** A failing test that `SceneCanvas` mounts the live scene view on a WebGL 2 runtime, alongside the existing test that it keeps the accessible fallback when neither backend exists. The live view is mocked at the module edge so jsdom never mounts an R3F canvas. +- [ ] **Step 2 (GREEN):** The gate asks `detectLivePreviewBackend() === 'unsupported'`. The barrel exports the probe and its type. +- [ ] **Step 3 (BLUE):** Review and a closing `refactor:` commit. + +### Task 3: The pane gate + +**Files:** `editor/shell/scene-pane.tsx`, `editor/shell/scene-pane.test.tsx`. + +- [ ] **Step 1 (RED):** A failing test that `ScenePane` renders the live canvas rather than the unavailable empty state when the runtime falls back to WebGL 2, and keeps the empty state when nothing can render. +- [ ] **Step 2 (GREEN):** The pane asks the probe and gates on `'unsupported'`. +- [ ] **Step 3 (BLUE):** Review and a closing `refactor:` commit. + +### Task 4: The notice + +**Files:** `editor/shell/scene-pane.tsx`, `editor/shell/scene-pane.css`, `editor/shell/scene-pane.test.tsx`. + +- [ ] **Step 1 (RED):** A failing test that the pane shows a notice saying the preview is running without WebGPU on the WebGL 2 branch, and shows no notice on WebGPU. +- [ ] **Step 2 (GREEN):** The inline `Banner` and its overlay rule. +- [ ] **Step 3 (BLUE):** Review and a closing `refactor:` commit. + +### Task 5: Dismissal + +**Files:** `editor/shell/scene-pane.tsx`, `editor/shell/scene-pane.test.tsx`. + +- [ ] **Step 1 (RED):** A failing test that pressing the notice's dismiss control removes it and leaves the live canvas mounted. +- [ ] **Step 2 (GREEN):** The dismissed flag. +- [ ] **Step 3 (BLUE):** Review and a closing `refactor:` commit. + +### Task 6: ADR-0174 and the check chain + +**Files:** `docs/knowledge/decisions/ADR-0174-live-pane-webgl-fallback.md`. + +- [ ] **Step 1:** Write the ADR: the reversal of the gate ADR-0004 deferred and ADR-0019 recorded, what made the reversal safe (ADR-0171's live-view CI lane, and ADR-0151's note that WebGL 2 is the only baselined backend), the three-value probe and why it sits in bridge, the notice, and the follow-ups. `pnpm knowledge:index` must exit 0 and every `related` slug must resolve to a file. +- [ ] **Step 2:** Run the full check chain, each exit code read on its own, and `pnpm rgb:audit` against `origin/main`. From 8ba095848a57fc8865c017a8b2acb439f5698e49 Mon Sep 17 00:00:00 2001 From: Dan Moore <9156191+drmrd@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:18:45 -0400 Subject: [PATCH 02/20] test: pin the live preview backend probe to a three way answer --- bridge/react/live-preview-backend.test.ts | 61 +++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 bridge/react/live-preview-backend.test.ts diff --git a/bridge/react/live-preview-backend.test.ts b/bridge/react/live-preview-backend.test.ts new file mode 100644 index 00000000..ad375725 --- /dev/null +++ b/bridge/react/live-preview-backend.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, afterEach, vi } from 'vitest' + +// The probe answers once per page load and reuses the answer, so every case +// loads a fresh copy of the module against the runtime it just stubbed. +async function detectBackend(): Promise { + vi.resetModules() + const { detectLivePreviewBackend } = await import('./live-preview-backend') + return detectLivePreviewBackend() +} + +// Stands in for the browser's canvas context lookup. jsdom has no WebGL, and its +// own getContext reports "not implemented" rather than returning null, so every +// case that cares about the WebGL 2 answer states it here. +function stubWebGl2Context(context: unknown): void { + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(((contextId: string) => + contextId === 'webgl2' ? context : null) as never) +} + +describe('detectLivePreviewBackend', () => { + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('reports WebGPU when the runtime exposes an adapter', async () => { + vi.stubGlobal('navigator', { gpu: {} }) + stubWebGl2Context({}) + + expect(await detectBackend()).toBe('webgpu') + }) + + it('reports WebGL 2 when WebGPU is absent but a WebGL 2 context is available', async () => { + vi.stubGlobal('navigator', {}) + stubWebGl2Context({}) + + expect(await detectBackend()).toBe('webgl2') + }) + + it('reports the preview unsupported when neither backend is available', async () => { + vi.stubGlobal('navigator', {}) + stubWebGl2Context(null) + + expect(await detectBackend()).toBe('unsupported') + }) + + it('creates at most one probe context however often it is asked', async () => { + vi.stubGlobal('navigator', {}) + stubWebGl2Context({}) + vi.resetModules() + const { detectLivePreviewBackend } = await import('./live-preview-backend') + + detectLivePreviewBackend() + detectLivePreviewBackend() + detectLivePreviewBackend() + + // A live 3D view renders many times, and browsers cap the number of live + // WebGL contexts a document may hold. A probe context per render would + // exhaust that cap and take the real scene canvas down with it. + expect(HTMLCanvasElement.prototype.getContext).toHaveBeenCalledTimes(1) + }) +}) From 3c7a3447953c46ebea8e12985c70066f3d100a4f Mon Sep 17 00:00:00 2001 From: Dan Moore <9156191+drmrd@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:19:17 -0400 Subject: [PATCH 03/20] feat: probe whether the live preview can render on WebGPU or WebGL 2 --- bridge/react/live-preview-backend.ts | 36 ++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 bridge/react/live-preview-backend.ts diff --git a/bridge/react/live-preview-backend.ts b/bridge/react/live-preview-backend.ts new file mode 100644 index 00000000..8d60bf75 --- /dev/null +++ b/bridge/react/live-preview-backend.ts @@ -0,0 +1,36 @@ +import { detectRenderBackend } from '../../engine' + +/** + * Which backend the live 3D preview will actually render through. The engine's + * `WebGPURenderer` targets WebGPU when the runtime exposes an adapter and falls back to + * its own WebGL 2 backend when it does not, so `'webgl2'` is a working preview and only + * `'unsupported'` means no preview at all. + */ +export type LivePreviewBackend = 'webgpu' | 'webgl2' | 'unsupported' + +// Browsers cap how many live WebGL contexts one document may hold, and the answer cannot +// change within a page load, so the probe context is created once and the verdict reused. +// A probe per render would exhaust the cap and take the scene canvas down with it. +let probedBackend: LivePreviewBackend | null = null + +function canCreateWebGl2Context(): boolean { + if (typeof document === 'undefined') { + return false + } + // A runtime can carry the WebGL2RenderingContext constructor and still refuse a context + // (a blocked GPU, WebGL switched off), so this asks for the context rather than the type. + return Boolean(document.createElement('canvas').getContext('webgl2')) +} + +function probeLivePreviewBackend(): LivePreviewBackend { + if (detectRenderBackend() === 'webgpu') { + return 'webgpu' + } + return canCreateWebGl2Context() ? 'webgl2' : 'unsupported' +} + +/** Reports the backend the live 3D preview will render through on this runtime. */ +export function detectLivePreviewBackend(): LivePreviewBackend { + probedBackend ??= probeLivePreviewBackend() + return probedBackend +} From fa82a23e43f59e912e6b026516c7d8b567037c55 Mon Sep 17 00:00:00 2001 From: Dan Moore <9156191+drmrd@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:19:57 -0400 Subject: [PATCH 04/20] refactor: close the live preview backend probe cycle From 86fd254230a2eb7f2a7d8a038934702056d6bf3b Mon Sep 17 00:00:00 2001 From: Dan Moore <9156191+drmrd@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:20:32 -0400 Subject: [PATCH 05/20] test: require the scene canvas to mount on a WebGL 2 runtime --- bridge/react/scene-canvas.test.tsx | 47 +++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/bridge/react/scene-canvas.test.tsx b/bridge/react/scene-canvas.test.tsx index 013373f5..0cf56c3a 100644 --- a/bridge/react/scene-canvas.test.tsx +++ b/bridge/react/scene-canvas.test.tsx @@ -1,18 +1,57 @@ import { describe, it, expect, afterEach, vi } from 'vitest' import { render, screen } from '@testing-library/react' import { SceneCanvas } from './scene-canvas' +import type { LivePreviewBackend } from './live-preview-backend' + +let mockBackend: LivePreviewBackend = 'unsupported' + +// The probe is mocked at the module edge so each case states the runtime it means +// rather than reaching for navigator and a canvas context. +vi.mock('./live-preview-backend', () => ({ + detectLivePreviewBackend: () => mockBackend, +})) + +// The live view mounts an R3F , which measures its parent through a +// ResizeObserver jsdom does not define. The stub keeps this file about which branch +// SceneCanvas takes, not about the renderer. +vi.mock('./webgpu-scene-view', () => ({ + // A component export legitimately keeps its PascalCase name in the mock. + // eslint-disable-next-line @typescript-eslint/naming-convention + WebGPUSceneView: () =>
, +})) describe('SceneCanvas', () => { afterEach(() => { - vi.unstubAllGlobals() + mockBackend = 'unsupported' }) - it('renders an accessible fallback when WebGPU is unavailable', () => { - vi.stubGlobal('navigator', {}) + it('renders an accessible fallback when the runtime can render no 3D at all', () => { + mockBackend = 'unsupported' render() const status = screen.getByRole('status') - expect(status).toHaveTextContent(/webgpu/i) + expect(status).toHaveTextContent(/3D view/i) + expect(screen.queryByTestId('webgpu-scene-view')).toBeNull() + }) + + it('mounts the live scene view when the runtime falls back to WebGL 2', () => { + // The renderer selects WebGPU when it is there and falls back to its own WebGL 2 + // backend when it is not, so a WebGL 2 runtime gets a working preview rather than + // an unsupported message. + mockBackend = 'webgl2' + + render() + + expect(screen.getByTestId('webgpu-scene-view')).toBeInTheDocument() + expect(screen.queryByRole('status')).toBeNull() + }) + + it('mounts the live scene view when the runtime exposes WebGPU', () => { + mockBackend = 'webgpu' + + render() + + expect(screen.getByTestId('webgpu-scene-view')).toBeInTheDocument() }) }) From 94b1f262de1416467d96f475743ef67f9309e5cd Mon Sep 17 00:00:00 2001 From: Dan Moore <9156191+drmrd@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:21:03 -0400 Subject: [PATCH 06/20] fix: mount the live 3D view on a WebGL 2 runtime instead of refusing it --- bridge/index.ts | 2 ++ bridge/react/scene-canvas.tsx | 11 ++++++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/bridge/index.ts b/bridge/index.ts index 0e92b137..20191892 100644 --- a/bridge/index.ts +++ b/bridge/index.ts @@ -52,6 +52,8 @@ export { useSceneGraph } from './react/use-scene-graph' export type { UseAutosaveOptions } from './react/use-autosave' export { useAutosave } from './react/use-autosave' export { SceneCanvas } from './react/scene-canvas' +export { detectLivePreviewBackend } from './react/live-preview-backend' +export type { LivePreviewBackend } from './react/live-preview-backend' export { SceneHarnessView, type HarnessScene } from './react/scene-harness-view' export type { SurfaceSelectionStore } from './selection/surface-selection-store' export { createSurfaceSelectionStore } from './selection/surface-selection-store' diff --git a/bridge/react/scene-canvas.tsx b/bridge/react/scene-canvas.tsx index ee04c790..88b566d9 100644 --- a/bridge/react/scene-canvas.tsx +++ b/bridge/react/scene-canvas.tsx @@ -1,13 +1,14 @@ -import { detectRenderBackend } from '../../engine' +import { detectLivePreviewBackend } from './live-preview-backend' import { WebGPUSceneView } from './webgpu-scene-view' -/** The 3D viewport. Renders the WebGPU scene when available, otherwise an accessible - * message. The WebGL2 fallback renderer arrives in a later phase. */ +/** The 3D viewport. Renders the live scene whenever the runtime can drive a backend, on + * WebGPU or on the renderer's own WebGL 2 fallback, and an accessible message when it + * can drive neither. */ export function SceneCanvas() { - if (detectRenderBackend() !== 'webgpu') { + if (detectLivePreviewBackend() === 'unsupported') { return (
- This 3D view requires a WebGPU-capable browser. + This browser cannot render the 3D view.
) } From 5328ef6657add2d25ca42ca44cd6dbe1152512ca Mon Sep 17 00:00:00 2001 From: Dan Moore <9156191+drmrd@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:21:10 -0400 Subject: [PATCH 07/20] refactor: close the scene canvas backend gate cycle From e714e80708415c09a6899fb36ac85f485479d50a Mon Sep 17 00:00:00 2001 From: Dan Moore <9156191+drmrd@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:21:57 -0400 Subject: [PATCH 08/20] test: require the scene pane to render a WebGL 2 preview --- editor/shell/scene-pane.test.tsx | 43 ++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/editor/shell/scene-pane.test.tsx b/editor/shell/scene-pane.test.tsx index 4a752cbe..595a7188 100644 --- a/editor/shell/scene-pane.test.tsx +++ b/editor/shell/scene-pane.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, afterEach, vi } from 'vitest' import { render, screen, waitFor, act } from '@testing-library/react' import type { SceneGraph } from '../../core' +import type { LivePreviewBackend } from '../../bridge' import { ScenePane } from './scene-pane' // An empty scene graph: no walls/rooms/openings/stairs/furniture, so the real @@ -37,6 +38,10 @@ const graphWithGeometry: SceneGraph = { // Mockable scene-graph state so each test can drive the empty/non-empty branch. let mockSceneGraph: SceneGraph = graphWithGeometry +// Which backend the runtime can drive. The pane asks the bridge probe rather than +// navigator directly, so each test states the runtime it means here. +let mockBackend: LivePreviewBackend = 'webgpu' + // When true, the mocked SceneCanvas suspends on its first render (throws a // promise) before resolving to the live stub. This mimics how R3F's // suspends its subtree until the renderer boots and the first frame is ready, @@ -67,30 +72,34 @@ vi.mock('../../bridge', () => ({ }, useSceneGraph: () => mockSceneGraph, useActiveFloorId: () => 'g', + detectLivePreviewBackend: () => mockBackend, })) describe('ScenePane', () => { afterEach(() => { - vi.unstubAllGlobals() mockSceneGraph = graphWithGeometry + mockBackend = 'webgpu' suspendSceneCanvasOnce = false sceneCanvasResolved = false resolveSceneCanvas = null }) - it('renders the styled empty-state fallback when WebGPU is unavailable', () => { - vi.stubGlobal('navigator', {}) + it('renders the styled empty-state fallback when the runtime can render no 3D at all', () => { + mockBackend = 'unsupported' const { container } = render() // The design-system EmptyState title, not a bare unstyled string. expect(screen.getByText(/3D preview unavailable/i)).toBeInTheDocument() + // Missing WebGPU is no longer the reason a preview is unavailable, so the copy + // must not blame it: a WebGL 2 browser reaches the live view instead. + expect(screen.queryByText(/WebGPU/i)).toBeNull() // Rendered through the EmptyState primitive (its section markup), not a raw div. expect(container.querySelector('.ds-status--empty')).not.toBeNull() }) it('reassures the user without nesting a duplicate region landmark', () => { - vi.stubGlobal('navigator', {}) + mockBackend = 'unsupported' const { container } = render() @@ -103,7 +112,7 @@ describe('ScenePane', () => { }) it('renders the live scene rather than the fallback when WebGPU is available', () => { - vi.stubGlobal('navigator', { gpu: {} }) + mockBackend = 'webgpu' render() @@ -113,8 +122,20 @@ describe('ScenePane', () => { expect(screen.getByTestId('live-scene-canvas')).toBeInTheDocument() }) + it('renders the live scene when the runtime falls back to WebGL 2', () => { + // The renderer targets WebGPU when it is there and falls back to its own WebGL 2 + // backend when it is not, and that fallback path is what every committed scene + // baseline renders through. A WebGL 2 browser therefore gets the preview. + mockBackend = 'webgl2' + + render() + + expect(screen.queryByText(/3D preview unavailable/i)).toBeNull() + expect(screen.getByTestId('live-scene-canvas')).toBeInTheDocument() + }) + it('keeps the live scene canvas mounted and overlays empty-floor guidance when the active floor has no geometry', async () => { - vi.stubGlobal('navigator', { gpu: {} }) + mockBackend = 'webgpu' mockSceneGraph = emptyGraph const { container } = render() @@ -140,7 +161,7 @@ describe('ScenePane', () => { }) it('does not unmount the live scene canvas when the active floor transitions from having geometry to being empty', () => { - vi.stubGlobal('navigator', { gpu: {} }) + mockBackend = 'webgpu' mockSceneGraph = graphWithGeometry const { rerender } = render() @@ -157,7 +178,7 @@ describe('ScenePane', () => { }) it('shows a loading fallback while the live 3D canvas boots, then the canvas', async () => { - vi.stubGlobal('navigator', { gpu: {} }) + mockBackend = 'webgpu' // Geometry is present, so the empty branch is not taken and ScenePane must // delegate to the live canvas. Make that canvas suspend on first render to // exercise the Suspense fallback ScenePane wraps it in. @@ -180,7 +201,7 @@ describe('ScenePane', () => { }) it('shows the loading placeholder for a canvas that mounts late already not-ready', async () => { - vi.stubGlobal('navigator', { gpu: {} }) + mockBackend = 'webgpu' mockSceneGraph = graphWithGeometry // The canvas suspends first, so it is not present in the tree at mount // time. When it resolves, it is inserted into the pane subtree already @@ -208,7 +229,7 @@ describe('ScenePane', () => { }) it('shows a quiet placeholder until the scene canvas signals its first frame is ready, then clears it', async () => { - vi.stubGlobal('navigator', { gpu: {} }) + mockBackend = 'webgpu' mockSceneGraph = graphWithGeometry render() @@ -235,7 +256,7 @@ describe('ScenePane', () => { }) it('shows only the loading placeholder over an empty floor until the scene is ready, then swaps to the empty-floor guidance', async () => { - vi.stubGlobal('navigator', { gpu: {} }) + mockBackend = 'webgpu' mockSceneGraph = emptyGraph render() From c3b6a78c3cf71090aa8ec62270c660ab0f2b2b72 Mon Sep 17 00:00:00 2001 From: Dan Moore <9156191+drmrd@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:22:17 -0400 Subject: [PATCH 09/20] fix: keep the 3D preview for browsers that render through WebGL 2 --- editor/shell/scene-pane.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/editor/shell/scene-pane.tsx b/editor/shell/scene-pane.tsx index eed6e733..864a5232 100644 --- a/editor/shell/scene-pane.tsx +++ b/editor/shell/scene-pane.tsx @@ -8,8 +8,12 @@ import { } from 'react' import { sceneGraphForFloor, sceneGraphHasGeometry } from '../../core' -import { detectRenderBackend } from '../../engine' -import { SceneCanvas, useActiveFloorId, useSceneGraph } from '../../bridge' +import { + SceneCanvas, + detectLivePreviewBackend, + useActiveFloorId, + useSceneGraph, +} from '../../bridge' import { LIVE_SCENE_CANVAS_TEST_ID, SCENE_READY_ATTRIBUTE, @@ -65,12 +69,12 @@ export function ScenePane(): ReactElement { const activeFloorId = useActiveFloorId() const paneRef = useRef(null) const isSceneReady = useSceneReady(paneRef) - if (detectRenderBackend() !== 'webgpu') { + if (detectLivePreviewBackend() === 'unsupported') { return ( ) } From 7c18d3b13253587a456847757c8da44e9ecf7d77 Mon Sep 17 00:00:00 2001 From: Dan Moore <9156191+drmrd@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:22:19 -0400 Subject: [PATCH 10/20] refactor: close the scene pane backend gate cycle From f4a4a089818fa8ce774f50b1b616f680df488fa2 Mon Sep 17 00:00:00 2001 From: Dan Moore <9156191+drmrd@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:23:07 -0400 Subject: [PATCH 11/20] test: require a dismissible notice when the preview runs without WebGPU --- editor/shell/scene-pane.test.tsx | 52 ++++++++++++++++++++++++++------ 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/editor/shell/scene-pane.test.tsx b/editor/shell/scene-pane.test.tsx index 595a7188..ba05059f 100644 --- a/editor/shell/scene-pane.test.tsx +++ b/editor/shell/scene-pane.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, afterEach, vi } from 'vitest' -import { render, screen, waitFor, act } from '@testing-library/react' +import { render, screen, waitFor, act, fireEvent } from '@testing-library/react' import type { SceneGraph } from '../../core' import type { LivePreviewBackend } from '../../bridge' import { ScenePane } from './scene-pane' @@ -75,15 +75,15 @@ vi.mock('../../bridge', () => ({ detectLivePreviewBackend: () => mockBackend, })) -describe('ScenePane', () => { - afterEach(() => { - mockSceneGraph = graphWithGeometry - mockBackend = 'webgpu' - suspendSceneCanvasOnce = false - sceneCanvasResolved = false - resolveSceneCanvas = null - }) +afterEach(() => { + mockSceneGraph = graphWithGeometry + mockBackend = 'webgpu' + suspendSceneCanvasOnce = false + sceneCanvasResolved = false + resolveSceneCanvas = null +}) +describe('ScenePane backend gating', () => { it('renders the styled empty-state fallback when the runtime can render no 3D at all', () => { mockBackend = 'unsupported' @@ -134,6 +134,40 @@ describe('ScenePane', () => { expect(screen.getByTestId('live-scene-canvas')).toBeInTheDocument() }) + it('says the preview is running without WebGPU when it falls back to WebGL 2', () => { + mockBackend = 'webgl2' + + render() + + // The two backends can shade a frame differently, so the pane says which one is + // driving rather than letting the difference look like a bug. + expect(screen.getByText(/running without WebGPU/i)).toBeInTheDocument() + // The notice sits beside a working preview, not in place of one. + expect(screen.getByTestId('live-scene-canvas')).toBeInTheDocument() + }) + + it('drops the backend notice once the user dismisses it', () => { + mockBackend = 'webgl2' + + render() + fireEvent.click(screen.getByRole('button', { name: /dismiss/i })) + + // One showing is the point: the message is worth saying, not worth keeping a + // stripe across the view for the rest of the visit. + expect(screen.queryByText(/running without WebGPU/i)).toBeNull() + expect(screen.getByTestId('live-scene-canvas')).toBeInTheDocument() + }) + + it('shows no backend notice when the preview runs on WebGPU', () => { + mockBackend = 'webgpu' + + render() + + expect(screen.queryByText(/running without WebGPU/i)).toBeNull() + }) +}) + +describe('ScenePane scene readiness', () => { it('keeps the live scene canvas mounted and overlays empty-floor guidance when the active floor has no geometry', async () => { mockBackend = 'webgpu' mockSceneGraph = emptyGraph From 4ceeaefafa127d19e9c20c8672fe56fb129546b7 Mon Sep 17 00:00:00 2001 From: Dan Moore <9156191+drmrd@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:25:45 -0400 Subject: [PATCH 12/20] feat: say when the 3D preview is running without WebGPU --- editor/shell/scene-pane.css | 21 +++++++++++++++++++++ editor/shell/scene-pane.tsx | 34 ++++++++++++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/editor/shell/scene-pane.css b/editor/shell/scene-pane.css index c7a1f85b..3acc53a7 100644 --- a/editor/shell/scene-pane.css +++ b/editor/shell/scene-pane.css @@ -21,3 +21,24 @@ justify-content: center; pointer-events: none; } + +/* + * The backend notice rides over the pane rather than taking a row of it, so the canvas + * keeps the height every measurement below it is built on. It is anchored bottom-right + * to leave the bottom-left camera-controls caption clear, and the wrapper ignores + * pointer input so an orbit drag passes through; the banner itself takes pointer input + * back so its dismiss button works. The width cap keeps the message to a readable + * measure on a wide pane. + */ +.scene-pane__notice { + position: absolute; + right: var(--space-3); + bottom: var(--space-3); + z-index: 20; + max-width: min(24rem, calc(100% - var(--space-6))); + pointer-events: none; +} + +.scene-pane__notice > * { + pointer-events: auto; +} diff --git a/editor/shell/scene-pane.tsx b/editor/shell/scene-pane.tsx index 864a5232..aeda0eac 100644 --- a/editor/shell/scene-pane.tsx +++ b/editor/shell/scene-pane.tsx @@ -18,11 +18,24 @@ import { LIVE_SCENE_CANVAS_TEST_ID, SCENE_READY_ATTRIBUTE, } from '../../bridge/react/scene-readiness' -import { EmptyState, LoadingState } from '../design-system' +import { Banner, EmptyState, LoadingState, type Notification } from '../design-system' import './scene-pane.css' const LIVE_SCENE_CANVAS_SELECTOR = `[data-testid="${LIVE_SCENE_CANVAS_TEST_ID}"]` +// The renderer falls back to its own WebGL 2 backend when a browser exposes no WebGPU +// adapter, and the two backends can shade the same frame differently. Saying so keeps +// that difference from reading as a defect. It is the design system's banner rather +// than a bespoke strip so it carries the shell's notice styling and dismiss control. +const WEBGL_FALLBACK_NOTICE: Notification = { + id: 'scene-pane-webgl-fallback', + tier: 'banner', + severity: 'info', + message: + 'The 3D preview is running without WebGPU. Shading and lighting can look slightly different here.', + dismissible: true, +} + // Whether the pane's own subtree reports its first frame ready: true when the live // canvas node is not (yet) present, so a not-yet-mounted or still-suspended canvas // never stacks a redundant loading placeholder on top of the Suspense fallback, and @@ -62,6 +75,21 @@ function useSceneReady(paneRef: RefObject): boolean { return isSceneReady } +// The notice shown while the preview renders through the renderer's WebGL 2 fallback. +// Dismissal is remembered for as long as the pane stays mounted and no longer: the +// message is worth one showing per visit, not a permanent stripe across the view. +function BackendNotice(): ReactElement | null { + const [isDismissed, setDismissed] = useState(false) + if (isDismissed) { + return null + } + return ( +
+ setDismissed(true)} /> +
+ ) +} + // The pane lives in the editor layer so the styled fallback can use the design // system, which the bridge layer cannot import. export function ScenePane(): ReactElement { @@ -69,7 +97,8 @@ export function ScenePane(): ReactElement { const activeFloorId = useActiveFloorId() const paneRef = useRef(null) const isSceneReady = useSceneReady(paneRef) - if (detectLivePreviewBackend() === 'unsupported') { + const backend = detectLivePreviewBackend() + if (backend === 'unsupported') { return ( }> + {backend === 'webgl2' ? : null} {isSceneReady ? null : (
From 5ac97d01a3f3c339dda3c20e1c6a731e03558205 Mon Sep 17 00:00:00 2001 From: Dan Moore <9156191+drmrd@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:25:47 -0400 Subject: [PATCH 13/20] refactor: close the WebGPU fallback notice cycle From 7cc93a9c86a43037116b2f977c0c066626b83eca Mon Sep 17 00:00:00 2001 From: Dan Moore <9156191+drmrd@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:27:17 -0400 Subject: [PATCH 14/20] docs: record the live pane WebGL 2 fallback in ADR-0174 --- .../ADR-0174-live-pane-webgl-fallback.md | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 docs/knowledge/decisions/ADR-0174-live-pane-webgl-fallback.md diff --git a/docs/knowledge/decisions/ADR-0174-live-pane-webgl-fallback.md b/docs/knowledge/decisions/ADR-0174-live-pane-webgl-fallback.md new file mode 100644 index 00000000..1d95176c --- /dev/null +++ b/docs/knowledge/decisions/ADR-0174-live-pane-webgl-fallback.md @@ -0,0 +1,113 @@ +--- +slug: decisions/ADR-0174-live-pane-webgl-fallback +title: 'ADR-0174: The live 3D pane renders on WebGL 2 instead of refusing to render' +type: decision +tags: [rendering, webgpu, webgl2, 3d-preview, bridge, editor] +related: + [ + decisions/ADR-0004-three-js-r3f-webgpu, + decisions/ADR-0019-bridge-dispatch-boundary, + decisions/ADR-0151-ambient-occlusion-render-pipeline, + decisions/ADR-0171-live-view-ci-render-lane, + ] +sourceFiles: + [ + bridge/react/live-preview-backend.ts, + bridge/react/scene-canvas.tsx, + editor/shell/scene-pane.tsx, + engine/renderer/create-renderer.ts, + ] +status: current +updated: 2026-09-07 +--- + +# ADR-0174: The live 3D pane renders on WebGL 2 instead of refusing to render + +## Status + +Current. Issue #476. This reverses the gate ADR-0004 chose and ADR-0019 recorded. + +## Context + +Two gates refused the live 3D view unless `navigator.gpu` was present. `editor/shell/scene-pane.tsx` +showed a design-system empty state reading "Your browser does not support WebGPU", and +`bridge/react/scene-canvas.tsx` showed a bare `role="status"` line reading "This 3D view requires a +WebGPU-capable browser". Both asked `detectRenderBackend()`, whose union is `'webgpu' | 'unsupported'` +because ADR-0004 deferred the WebGL 2 path as a post-MVP fast-follow. + +The deferral no longer matches the renderer. `createSceneRenderer` builds a `WebGPURenderer`, which +picks WebGPU when an adapter is there and drops to its own WebGL 2 backend when it is not, and +`forceWebGL` pins that same backend for the deterministic harness. Every committed scene baseline is +therefore a WebGL 2 render (ADR-0151), so the path the gates refused is the path CI has been +exercising all along. A browser with WebGL 2 and no WebGPU was told the preview was unavailable while +the code that would have served it sat one call away. + +What was missing was a gate on the live path itself. ADR-0151 named the backend split as an uncovered +risk, and the live view had no pixel coverage until ADR-0171 put the live-view spec on a macOS CI +runner with a real WebGPU adapter. With that lane in place, a regression in the live render fails a +required job, which is what makes widening the gate safe to do now. + +## Decision + +The pane renders whenever the runtime can drive any 3D backend, and says which one it got. + +1. **A three-value probe replaces the two-value question.** `detectLivePreviewBackend()` + (`bridge/react/live-preview-backend.ts`) returns `'webgpu'`, `'webgl2'`, or `'unsupported'`. It + delegates the WebGPU half to the engine's existing `detectRenderBackend()` and answers the WebGL 2 + half by asking a detached canvas for a `webgl2` context. Asking for the context rather than + checking for the `WebGL2RenderingContext` constructor is deliberate: a runtime can carry the type + and still refuse a context on a blocked GPU. +2. **The probe runs once per page load.** Browsers cap how many live WebGL contexts one document may + hold, and both callers sit on a render path. A probe context per render would exhaust the cap and + take the scene canvas down with it, so the verdict is cached in the module. +3. **Only `'unsupported'` still earns a refusal.** `SceneCanvas` mounts the live view on `'webgpu'` + and on `'webgl2'`, and `ScenePane` keeps its styled empty state for `'unsupported'` alone. The + empty state no longer names WebGPU, since a missing adapter is no longer the reason a preview is + unavailable. +4. **`'webgl2'` raises a dismissible notice over the pane.** It is the design system's `Banner`, so + it carries the shell's notice styling and dismiss control rather than a bespoke strip. It says the + preview is running without WebGPU and that shading and lighting can look slightly different. + Dismissal lasts as long as the pane stays mounted. The notice is positioned over the canvas rather + than in a layout row, so the pane keeps the height the canvas measurement depends on, and the + wrapper ignores pointer input so an orbit drag passes through it. + +### The probe lives in `bridge/`, not `engine/` + +Widening `RenderBackend` in `engine/renderer/detect-backend.ts` would be the tidier home, and it is +the recorded follow-up. The probe went to `bridge/react/` for two reasons. It needs no Three.js: it +reads `navigator` and a canvas context, so it breaks no layer invariant. And `bridge/` is the lowest +layer both callers can reach, since `editor/` may import `bridge/` but `bridge/` may not import +`editor/`. The engine's `detectRenderBackend()` keeps its narrow meaning, "is a WebGPU adapter +present", and the bridge probe composes on top of it. + +### The gates could not simply be deleted + +Deleting the bridge gate outright would mount the R3F `` in jsdom, where `react-use-measure` +needs a `ResizeObserver` the unit environment does not define, so the editor shell and app unit tests +would fail on a renderer they never meant to exercise. jsdom answers the probe with no WebGPU and no +WebGL 2 context, so those tests keep the fallback branch they had. + +## Consequences + +- A WebGL 2 browser gets a working 3D preview. That is the whole of issue #476. +- No baseline moves. The `scene-*` specs run in the `scene-webgl` Playwright project, where + `navigator.gpu` is present on the development Mac and on the macOS runner, so they take the + `'webgpu'` branch and render what they rendered before. The `home.png` baseline is captured on the + default view, which has no 3D region at all. +- The chromium, firefox, and webkit end-to-end projects now mount the live view where they used to + get the fallback line. The one spec that visits the pane there measures the region height and holds + either way, so no end-to-end file changed. +- WebGL 2 is a real render, not a degraded stub, but it is not pixel-identical to WebGPU. The notice + is what keeps that difference from reading as a defect, and the pane has no way to know how large + the difference is on a given frame. +- `detectRenderBackend()` is now called from one place, the bridge probe. Folding the WebGL 2 leg + into the engine helper and retiring the bridge module is a follow-up, along with a live-view + end-to-end case that pins the WebGL 2 branch and the notice. + +## References + +- Issue #476. +- ADR-0004 (the deferral this reverses), ADR-0019 (the bridge seam that carried the gate). +- ADR-0151 (WebGL 2 is the only baselined backend), ADR-0171 (the live-view CI lane that made this + safe to widen). +- `docs/plans/2026-09-07-live-pane-webgl-fallback.md`. From 74b17fde052cf7523f084b38ef63a96be3de4653 Mon Sep 17 00:00:00 2001 From: Dan Moore <9156191+drmrd@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:32:24 -0400 Subject: [PATCH 15/20] docs: record the live pane WebGL 2 fallback plan as executed --- .../2026-09-07-live-pane-webgl-fallback.md | 38 ++++++++----------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/docs/plans/2026-09-07-live-pane-webgl-fallback.md b/docs/plans/2026-09-07-live-pane-webgl-fallback.md index db321a3d..8e119737 100644 --- a/docs/plans/2026-09-07-live-pane-webgl-fallback.md +++ b/docs/plans/2026-09-07-live-pane-webgl-fallback.md @@ -32,45 +32,39 @@ Neither gate can simply be deleted. Removing the bridge gate outright would moun **Files:** `bridge/react/live-preview-backend.ts`, `bridge/react/live-preview-backend.test.ts`. -- [ ] **Step 1 (RED):** A failing test that `detectLivePreviewBackend()` reports `'webgpu'` with `navigator.gpu` present, `'webgl2'` with no `navigator.gpu` but an obtainable `webgl2` canvas context, and `'unsupported'` when neither is there. -- [ ] **Step 2 (GREEN):** The module. It delegates the WebGPU question to the engine's `detectRenderBackend()` and probes a detached canvas for the WebGL 2 context, treating a throw as no support. -- [ ] **Step 3 (BLUE):** Clean-code review and a closing `refactor:` commit. +- [x] **Step 1 (RED):** A failing test that `detectLivePreviewBackend()` reports `'webgpu'` with `navigator.gpu` present, `'webgl2'` with no `navigator.gpu` but an obtainable `webgl2` canvas context, and `'unsupported'` when neither is there. +- [x] **Step 2 (GREEN):** The module. It delegates the WebGPU question to the engine's `detectRenderBackend()` and probes a detached canvas for the WebGL 2 context, treating a throw as no support. +- [x] **Step 3 (BLUE):** Clean-code review and a closing `refactor:` commit. ### Task 2: The bridge gate **Files:** `bridge/react/scene-canvas.tsx`, `bridge/react/scene-canvas.test.tsx`, `bridge/index.ts`. -- [ ] **Step 1 (RED):** A failing test that `SceneCanvas` mounts the live scene view on a WebGL 2 runtime, alongside the existing test that it keeps the accessible fallback when neither backend exists. The live view is mocked at the module edge so jsdom never mounts an R3F canvas. -- [ ] **Step 2 (GREEN):** The gate asks `detectLivePreviewBackend() === 'unsupported'`. The barrel exports the probe and its type. -- [ ] **Step 3 (BLUE):** Review and a closing `refactor:` commit. +- [x] **Step 1 (RED):** A failing test that `SceneCanvas` mounts the live scene view on a WebGL 2 runtime, alongside the existing test that it keeps the accessible fallback when neither backend exists. The live view is mocked at the module edge so jsdom never mounts an R3F canvas. +- [x] **Step 2 (GREEN):** The gate asks `detectLivePreviewBackend() === 'unsupported'`. The barrel exports the probe and its type. +- [x] **Step 3 (BLUE):** Review and a closing `refactor:` commit. ### Task 3: The pane gate **Files:** `editor/shell/scene-pane.tsx`, `editor/shell/scene-pane.test.tsx`. -- [ ] **Step 1 (RED):** A failing test that `ScenePane` renders the live canvas rather than the unavailable empty state when the runtime falls back to WebGL 2, and keeps the empty state when nothing can render. -- [ ] **Step 2 (GREEN):** The pane asks the probe and gates on `'unsupported'`. -- [ ] **Step 3 (BLUE):** Review and a closing `refactor:` commit. +- [x] **Step 1 (RED):** A failing test that `ScenePane` renders the live canvas rather than the unavailable empty state when the runtime falls back to WebGL 2, and keeps the empty state when nothing can render. +- [x] **Step 2 (GREEN):** The pane asks the probe and gates on `'unsupported'`. +- [x] **Step 3 (BLUE):** Review and a closing `refactor:` commit. ### Task 4: The notice **Files:** `editor/shell/scene-pane.tsx`, `editor/shell/scene-pane.css`, `editor/shell/scene-pane.test.tsx`. -- [ ] **Step 1 (RED):** A failing test that the pane shows a notice saying the preview is running without WebGPU on the WebGL 2 branch, and shows no notice on WebGPU. -- [ ] **Step 2 (GREEN):** The inline `Banner` and its overlay rule. -- [ ] **Step 3 (BLUE):** Review and a closing `refactor:` commit. +- [x] **Step 1 (RED):** A failing test that the pane shows a notice saying the preview is running without WebGPU on the WebGL 2 branch, shows no notice on WebGPU, and drops the notice once the dismiss control is pressed. +- [x] **Step 2 (GREEN):** The inline `Banner`, its overlay rule, and the dismissed flag. +- [x] **Step 3 (BLUE):** Review and a closing `refactor:` commit. -### Task 5: Dismissal +**Outcome:** the dismissal was first planned as its own cycle. Rendering the design system's `Banner` with `dismissible: true` brings its dismiss control along, so a separate cycle would have had no failing test to open it. The dismissal assertion moved into this cycle's red instead. -**Files:** `editor/shell/scene-pane.tsx`, `editor/shell/scene-pane.test.tsx`. - -- [ ] **Step 1 (RED):** A failing test that pressing the notice's dismiss control removes it and leaves the live canvas mounted. -- [ ] **Step 2 (GREEN):** The dismissed flag. -- [ ] **Step 3 (BLUE):** Review and a closing `refactor:` commit. - -### Task 6: ADR-0174 and the check chain +### Task 5: ADR-0174 and the check chain **Files:** `docs/knowledge/decisions/ADR-0174-live-pane-webgl-fallback.md`. -- [ ] **Step 1:** Write the ADR: the reversal of the gate ADR-0004 deferred and ADR-0019 recorded, what made the reversal safe (ADR-0171's live-view CI lane, and ADR-0151's note that WebGL 2 is the only baselined backend), the three-value probe and why it sits in bridge, the notice, and the follow-ups. `pnpm knowledge:index` must exit 0 and every `related` slug must resolve to a file. -- [ ] **Step 2:** Run the full check chain, each exit code read on its own, and `pnpm rgb:audit` against `origin/main`. +- [x] **Step 1:** Write the ADR: the reversal of the gate ADR-0004 deferred and ADR-0019 recorded, what made the reversal safe (ADR-0171's live-view CI lane, and ADR-0151's note that WebGL 2 is the only baselined backend), the three-value probe and why it sits in bridge, the notice, and the follow-ups. `pnpm knowledge:index` must exit 0 and every `related` slug must resolve to a file. +- [x] **Step 2:** Run the full check chain, each exit code read on its own, and `pnpm rgb:audit` against `origin/main`. From 2cb44ea2e5c7303ebcb3234771c5853d33e85467 Mon Sep 17 00:00:00 2001 From: Dan Moore <9156191+drmrd@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:37:09 -0400 Subject: [PATCH 16/20] test: require the backend probe to survive a throwing context request --- bridge/react/live-preview-backend.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/bridge/react/live-preview-backend.test.ts b/bridge/react/live-preview-backend.test.ts index ad375725..8981a79a 100644 --- a/bridge/react/live-preview-backend.test.ts +++ b/bridge/react/live-preview-backend.test.ts @@ -43,6 +43,19 @@ describe('detectLivePreviewBackend', () => { expect(await detectBackend()).toBe('unsupported') }) + it('reports the preview unsupported when the context probe throws', async () => { + vi.stubGlobal('navigator', {}) + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation((() => { + throw new Error('context creation blocked') + }) as never) + + // Privacy-hardening extensions throw from getContext rather than returning null, + // precisely to defeat probes like this one. The probe runs during render and the + // application mounts no error boundary, so a throw escaping here would blank the + // whole editor rather than costing the user a 3D preview. + expect(await detectBackend()).toBe('unsupported') + }) + it('creates at most one probe context however often it is asked', async () => { vi.stubGlobal('navigator', {}) stubWebGl2Context({}) From bcafdf014012ab53f112f4e31510065b106135fe Mon Sep 17 00:00:00 2001 From: Dan Moore <9156191+drmrd@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:37:30 -0400 Subject: [PATCH 17/20] fix: keep a refused WebGL 2 context probe from blanking the editor --- bridge/react/live-preview-backend.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/bridge/react/live-preview-backend.ts b/bridge/react/live-preview-backend.ts index 8d60bf75..41981483 100644 --- a/bridge/react/live-preview-backend.ts +++ b/bridge/react/live-preview-backend.ts @@ -17,9 +17,17 @@ function canCreateWebGl2Context(): boolean { if (typeof document === 'undefined') { return false } - // A runtime can carry the WebGL2RenderingContext constructor and still refuse a context - // (a blocked GPU, WebGL switched off), so this asks for the context rather than the type. - return Boolean(document.createElement('canvas').getContext('webgl2')) + try { + // A runtime can carry the WebGL2RenderingContext constructor and still refuse a context + // (a blocked GPU, WebGL switched off), so this asks for the context rather than the type. + return Boolean(document.createElement('canvas').getContext('webgl2')) + } catch { + // Privacy-hardening extensions throw from getContext rather than returning null, to + // defeat probes like this one. The probe runs during render and the application mounts + // no error boundary, so a throw escaping here would blank the whole editor instead of + // costing the user a 3D preview. + return false + } } function probeLivePreviewBackend(): LivePreviewBackend { From d3b0763bc47ed7630a5a2218d29e6a9d52a26c17 Mon Sep 17 00:00:00 2001 From: Dan Moore <9156191+drmrd@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:37:44 -0400 Subject: [PATCH 18/20] refactor: document the cached answer on the live preview backend probe --- bridge/react/live-preview-backend.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/bridge/react/live-preview-backend.ts b/bridge/react/live-preview-backend.ts index 41981483..95b19158 100644 --- a/bridge/react/live-preview-backend.ts +++ b/bridge/react/live-preview-backend.ts @@ -37,7 +37,14 @@ function probeLivePreviewBackend(): LivePreviewBackend { return canCreateWebGl2Context() ? 'webgl2' : 'unsupported' } -/** Reports the backend the live 3D preview will render through on this runtime. */ +/** + * Reports the backend the live 3D preview will render through on this runtime. + * + * The answer is probed once and then cached for the life of the module, so a test that + * renders a real consumer without mocking this module gets whichever runtime the first + * caller in that file saw. Drive the answer with a module mock, or reload the module with + * `vi.resetModules()` between cases. + */ export function detectLivePreviewBackend(): LivePreviewBackend { probedBackend ??= probeLivePreviewBackend() return probedBackend From 1411d66cfa4f2306a0c513ee487ce63433b4fca7 Mon Sep 17 00:00:00 2001 From: Dan Moore <9156191+drmrd@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:38:10 -0400 Subject: [PATCH 19/20] docs: record the probe throw guard in ADR-0174 and the plan --- .../decisions/ADR-0174-live-pane-webgl-fallback.md | 5 ++++- docs/plans/2026-09-07-live-pane-webgl-fallback.md | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/knowledge/decisions/ADR-0174-live-pane-webgl-fallback.md b/docs/knowledge/decisions/ADR-0174-live-pane-webgl-fallback.md index 1d95176c..1a101698 100644 --- a/docs/knowledge/decisions/ADR-0174-live-pane-webgl-fallback.md +++ b/docs/knowledge/decisions/ADR-0174-live-pane-webgl-fallback.md @@ -56,7 +56,10 @@ The pane renders whenever the runtime can drive any 3D backend, and says which o delegates the WebGPU half to the engine's existing `detectRenderBackend()` and answers the WebGL 2 half by asking a detached canvas for a `webgl2` context. Asking for the context rather than checking for the `WebGL2RenderingContext` constructor is deliberate: a runtime can carry the type - and still refuse a context on a blocked GPU. + and still refuse a context on a blocked GPU. A refusal that arrives as a throw counts as no + support too. Privacy-hardening extensions throw from `getContext` to defeat exactly this kind of + probe, the probe runs during render, and the application mounts no error boundary, so an escaping + throw would blank the editor rather than cost the user a preview. 2. **The probe runs once per page load.** Browsers cap how many live WebGL contexts one document may hold, and both callers sit on a render path. A probe context per render would exhaust the cap and take the scene canvas down with it, so the verdict is cached in the module. diff --git a/docs/plans/2026-09-07-live-pane-webgl-fallback.md b/docs/plans/2026-09-07-live-pane-webgl-fallback.md index 8e119737..6ac1fbda 100644 --- a/docs/plans/2026-09-07-live-pane-webgl-fallback.md +++ b/docs/plans/2026-09-07-live-pane-webgl-fallback.md @@ -68,3 +68,10 @@ Neither gate can simply be deleted. Removing the bridge gate outright would moun - [x] **Step 1:** Write the ADR: the reversal of the gate ADR-0004 deferred and ADR-0019 recorded, what made the reversal safe (ADR-0171's live-view CI lane, and ADR-0151's note that WebGL 2 is the only baselined backend), the three-value probe and why it sits in bridge, the notice, and the follow-ups. `pnpm knowledge:index` must exit 0 and every `related` slug must resolve to a file. - [x] **Step 2:** Run the full check chain, each exit code read on its own, and `pnpm rgb:audit` against `origin/main`. + +### Task 6: Review findings + +**Files:** `bridge/react/live-preview-backend.ts` and its test, `docs/knowledge/decisions/ADR-0174-live-pane-webgl-fallback.md`. + +- [x] **Step 1:** The clean-code review raised one must-fix: the probe called `getContext` unguarded, against this plan's own Task 1 wording. Privacy-hardening extensions throw from `getContext` to defeat feature probes, the probe runs during render, and the application mounts no error boundary, so a throw would blank the editor. Red, green, and a closing blue: a failing test for the throwing runtime, the `try`/`catch`, and a doc comment on `detectLivePreviewBackend` recording that the answer is cached per module instance (the review's second finding). +- [x] **Step 2:** ADR-0174 records the throw case alongside the constructor-check reasoning. From f8f0da7f25f30035fda5e20c76372166c0781e16 Mon Sep 17 00:00:00 2001 From: Dan Moore <9156191+drmrd@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:51:16 -0400 Subject: [PATCH 20/20] docs: update the debugging playbook for the landed WebGL 2 fallback --- .claude/skills/vernacular-debugging-playbook/SKILL.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.claude/skills/vernacular-debugging-playbook/SKILL.md b/.claude/skills/vernacular-debugging-playbook/SKILL.md index aab3470e..3bf36d50 100644 --- a/.claude/skills/vernacular-debugging-playbook/SKILL.md +++ b/.claude/skills/vernacular-debugging-playbook/SKILL.md @@ -91,10 +91,10 @@ Story: the live preview is a second scene-construction path that has repeatedly The two paths: -| Path | Entry | Scene construction | Backend | -| ------------ | --------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | -| Harness | `?fixture=scene-harness` URL param, handled in `app/app.tsx`, rendered by `bridge/react/scene-harness-view.tsx` | `buildFramedScene` (`bridge/react/framed-scene.ts`) calls engine `buildScene` (`engine/scene/build-scene.ts`): one-shot full build | Deterministic WebGL 2 | -| Live preview | Editor preview/split pane (`WebGPUSceneView`, `bridge/react/webgpu-scene-view.tsx`) | `useFramedScene` > `createFramedSceneReconciler` (`bridge/react/framed-scene-reconciler.ts`): incremental reconcile of subgroups | WebGPU (hard gate, #476 open) | +| Path | Entry | Scene construction | Backend | +| ------------ | --------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| Harness | `?fixture=scene-harness` URL param, handled in `app/app.tsx`, rendered by `bridge/react/scene-harness-view.tsx` | `buildFramedScene` (`bridge/react/framed-scene.ts`) calls engine `buildScene` (`engine/scene/build-scene.ts`): one-shot full build | Deterministic WebGL 2 | +| Live preview | Editor preview/split pane (`WebGPUSceneView`, `bridge/react/webgpu-scene-view.tsx`) | `useFramedScene` > `createFramedSceneReconciler` (`bridge/react/framed-scene-reconciler.ts`): incremental reconcile of subgroups | WebGPU, or its WebGL 2 fallback (#476 landed; only a browser with neither shows the unsupported message) | Discriminating experiment: build and serve (`pnpm build`, `pnpm preview`), then load `http://localhost:4173/?fixture=scene-harness` (add `&scene=` for a named fixture; param catalog: vernacular-config-and-flags). Compare against the live pane on the same content. @@ -114,7 +114,7 @@ Check in this order: document.querySelector('canvas')?.getBoundingClientRect() ``` A 300x150 (or near-zero) rect means the CSS layout collapsed. Fix the layout; the renderer is innocent. -2. Fallback message "Your browser does not support WebGPU..." (`editor/shell/scene-pane.tsx`): the live pane hard-gates on `detectRenderBackend() === 'webgpu'` (also `bridge/react/scene-canvas.tsx`). Issue #476 (open as of 2026-07-05) tracks falling back to WebGL 2 instead. +2. Fallback message "This browser cannot render the 3D view." (`editor/shell/scene-pane.tsx`): the live pane refuses only when `detectLivePreviewBackend()` (`bridge/react/live-preview-backend.ts`) answers `unsupported`, meaning neither WebGPU nor WebGL 2 exists. On WebGL 2 it renders with a dismissible notice instead (#476, ADR-0174). 3. Empty state "Nothing to show in 3D yet" (`editor/shell/scene-pane.tsx`): the canvas only mounts once the floor has geometry. Draw a wall first. 4. Only then suspect the render path (entry 3, then vernacular-rendering-defect-campaign).