diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index cfa7d09f..60f0d8c7 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -77,31 +77,14 @@ const AppShell: React.FC = () => { }; return fn as typeof navigateRaw; }, [navigateRaw]); - // Rapid app-list clicks each mount a fresh ViewEditor (its WebGL placeholder + preview webview); churning heavy WebGL apps faster than the GPU recycles surfaces kills the GPU/host renderer and takes the whole app down. Open the first click at once (idle = no click in 400ms), then during a burst fire NOTHING until the clicks stop, so a spam-burst lands on at most two apps instead of a dozen. - const lastAppClickAt = useRef(0); - const pendingAppNav = useRef(null); - const appNavTimer = useRef | null>(null); - const APP_NAV_IDLE_MS = 400; - const APP_NAV_TRAILING_MS = 320; + // Navigate to an app instantly on click. The old debounce here swallowed clicks the + // user could see (felt broken) and never actually fixed the crash, since letting each + // app load defeats the debounce anyway. The real GPU-churn source (the WebGL loading + // placeholder) is now CSS, and the 250ms preview gate still skips webviews for apps + // switched-past too fast, so instant navigation is safe. const navigateToApp = useCallback((id: string) => { - const now = Date.now(); - const idle = now - lastAppClickAt.current >= APP_NAV_IDLE_MS; - lastAppClickAt.current = now; - if (appNavTimer.current) clearTimeout(appNavTimer.current); - if (idle) { - pendingAppNav.current = null; - navigate(`/apps/${id}`); - return; - } - pendingAppNav.current = id; - appNavTimer.current = setTimeout(() => { - appNavTimer.current = null; - const target = pendingAppNav.current; - pendingAppNav.current = null; - if (target) navigate(`/apps/${target}`); - }, APP_NAV_TRAILING_MS); + navigate(`/apps/${id}`); }, [navigate]); - useEffect(() => () => { if (appNavTimer.current) clearTimeout(appNavTimer.current); }, []); const location = useLocation(); // React Router (HashRouter) stores a monotonic index in history state. location // re-renders on every nav, by which point window.history.state.idx is updated. diff --git a/frontend/src/app/components/feedback/PixelBlast.tsx b/frontend/src/app/components/feedback/PixelBlast.tsx index 44aab635..b324130f 100644 --- a/frontend/src/app/components/feedback/PixelBlast.tsx +++ b/frontend/src/app/components/feedback/PixelBlast.tsx @@ -1,9 +1,11 @@ -// Bayer-dithering pixel-blast background; same shader as webapp_template splash so cold-start phases match. +// Brand-tinted pixel-dither loading background. CSS only, on purpose: this is the +// app-card loading placeholder and it mounts/unmounts every time you open an app, so +// the old WebGL2 version churned GL contexts faster than the GPU could recycle them and +// killed the whole renderer under app-list spam (worst with no chat anchoring the GPU +// process). A tiled dot-grid + gentle pulse reads the same at a glance with zero GPU +// context churn. Same props as before so call sites don't change. -import React, { useEffect, useRef } from 'react'; - -// Module-level epoch so remounts pick up where the previous mount left off in the noise loop. -const PIXEL_BLAST_EPOCH = performance.now(); +import React from 'react'; interface PixelBlastProps { color?: string; @@ -20,174 +22,30 @@ const PixelBlast: React.FC = ({ edgeFade = 0.3, style, }) => { - const canvasRef = useRef(null); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - const gl = canvas.getContext('webgl2', { antialias: true, alpha: true }); - if (!gl) return; - - const VS = `#version 300 es -in vec2 a; void main() { gl_Position = vec4(a, 0.0, 1.0); }`; - const FS = `#version 300 es -precision highp float; -out vec4 fragColor; -uniform vec2 uResolution; -uniform float uTime; -uniform float uPixelSize; -uniform vec3 uColor; -uniform float uEdgeFade; -float Bayer2(vec2 a){ a = floor(a); return fract(a.x/2.0 + a.y*a.y*0.75); } -#define Bayer4(a) (Bayer2(0.5*(a))*0.25 + Bayer2(a)) -#define Bayer8(a) (Bayer4(0.5*(a))*0.25 + Bayer2(a)) -float hash11(float n){ return fract(sin(n)*43758.5453); } -float vnoise(vec3 p){ - vec3 ip = floor(p); vec3 fp = fract(p); - float n000 = hash11(dot(ip + vec3(0,0,0), vec3(1.0,57.0,113.0))); - float n100 = hash11(dot(ip + vec3(1,0,0), vec3(1.0,57.0,113.0))); - float n010 = hash11(dot(ip + vec3(0,1,0), vec3(1.0,57.0,113.0))); - float n110 = hash11(dot(ip + vec3(1,1,0), vec3(1.0,57.0,113.0))); - float n001 = hash11(dot(ip + vec3(0,0,1), vec3(1.0,57.0,113.0))); - float n101 = hash11(dot(ip + vec3(1,0,1), vec3(1.0,57.0,113.0))); - float n011 = hash11(dot(ip + vec3(0,1,1), vec3(1.0,57.0,113.0))); - float n111 = hash11(dot(ip + vec3(1,1,1), vec3(1.0,57.0,113.0))); - vec3 w = fp*fp*fp*(fp*(fp*6.0-15.0)+10.0); - float x00 = mix(n000,n100,w.x); float x10 = mix(n010,n110,w.x); - float x01 = mix(n001,n101,w.x); float x11 = mix(n011,n111,w.x); - float y0 = mix(x00,x10,w.y); float y1 = mix(x01,x11,w.y); - return mix(y0,y1,w.z) * 2.0 - 1.0; -} -float fbm2(vec2 uv, float t){ - vec3 p = vec3(uv * 2.0, t); - float amp = 1.0; float freq = 1.0; float sum = 1.0; - for (int i = 0; i < 5; i++) { - sum += amp * vnoise(p * freq); - freq *= 1.25; - } - return sum * 0.5 + 0.5; -} -void main(){ - // 137.5 (golden-ratio angle) offsets off the FBM singularities so the center axes don't bright-stripe. - vec2 fragCoord = gl_FragCoord.xy - uResolution * 0.5 + vec2(137.5, 137.5); - float aspectRatio = uResolution.x / uResolution.y; - float cellPixelSize = 8.0 * uPixelSize; - vec2 cellId = floor(fragCoord / cellPixelSize); - vec2 cellCoord = cellId * cellPixelSize; - vec2 uv = cellCoord / uResolution * vec2(aspectRatio, 1.0); - float base = fbm2(uv, uTime * 0.05); - // Density tuned to read as ambient texture, not lumpy blob. -0.55 - // produced visible bright clusters around the noise peaks; pulling - // back to -0.62 evens out the field and the brand color reads as a - // diffuse haze instead of localized bright spots. - base = base * 0.5 - 0.62; - float feed = base + 0.5 * 0.3; - float bayer = Bayer8(fragCoord / uPixelSize) - 0.5; - float bw = step(0.5, feed + bayer); - vec2 norm = gl_FragCoord.xy / uResolution; - float edge = min(min(norm.x, norm.y), min(1.0 - norm.x, 1.0 - norm.y)); - float fade = smoothstep(0.0, uEdgeFade, edge); - float M = bw * fade; - vec3 srgb = mix(uColor * 12.92, 1.055 * pow(uColor, vec3(1.0/2.4)) - 0.055, step(0.0031308, uColor)); - fragColor = vec4(srgb, M); -}`; - - function compile(type: number, src: string): WebGLShader | null { - const s = gl!.createShader(type)!; - gl!.shaderSource(s, src); - gl!.compileShader(s); - if (!gl!.getShaderParameter(s, gl!.COMPILE_STATUS)) return null; - return s; - } - const vs = compile(gl.VERTEX_SHADER, VS); - const fs = compile(gl.FRAGMENT_SHADER, FS); - if (!vs || !fs) return; - const prog = gl.createProgram()!; - gl.attachShader(prog, vs); - gl.attachShader(prog, fs); - gl.linkProgram(prog); - if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) return; - gl.useProgram(prog); - - const buf = gl.createBuffer(); - gl.bindBuffer(gl.ARRAY_BUFFER, buf); - gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, -1,1, 1,-1, 1,1]), gl.STATIC_DRAW); - const aLoc = gl.getAttribLocation(prog, 'a'); - gl.enableVertexAttribArray(aLoc); - gl.vertexAttribPointer(aLoc, 2, gl.FLOAT, false, 0, 0); - - const uResolution = gl.getUniformLocation(prog, 'uResolution'); - const uTime = gl.getUniformLocation(prog, 'uTime'); - const uPixelSize = gl.getUniformLocation(prog, 'uPixelSize'); - const uColor = gl.getUniformLocation(prog, 'uColor'); - const uEdgeFade = gl.getUniformLocation(prog, 'uEdgeFade'); - - const hex = color.replace('#', ''); - const r = parseInt(hex.substring(0, 2), 16) / 255; - const g = parseInt(hex.substring(2, 4), 16) / 255; - const b = parseInt(hex.substring(4, 6), 16) / 255; - gl.uniform3f(uColor, r, g, b); - gl.uniform1f(uEdgeFade, edgeFade); - - const dpr = Math.min(window.devicePixelRatio || 1, 2); - function resize() { - const w = Math.floor(canvas!.clientWidth * dpr); - const h = Math.floor(canvas!.clientHeight * dpr); - if (canvas!.width !== w || canvas!.height !== h) { - canvas!.width = w; - canvas!.height = h; - } - gl!.viewport(0, 0, w, h); - gl!.uniform2f(uResolution, w, h); - gl!.uniform1f(uPixelSize, pixelSize * dpr); - } - resize(); - const ro = new ResizeObserver(resize); - ro.observe(canvas); - - gl.enable(gl.BLEND); - gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); - - // Use the module-level epoch so animation time is continuous across - // mounts. New cards opened later in the session pick up at the - // current epoch-relative time rather than restarting from 0. - let raf = 0; - const MIN_FRAME_MS = 1000 / 30; - let lastFrameAt = 0; - function frame() { - const now = performance.now(); - if (now - lastFrameAt >= MIN_FRAME_MS) { - lastFrameAt = now; - const t = (now - PIXEL_BLAST_EPOCH) / 1000; - gl!.uniform1f(uTime, t * speed); - gl!.clearColor(0.10, 0.10, 0.10, 1); - gl!.clear(gl!.COLOR_BUFFER_BIT); - gl!.drawArrays(gl!.TRIANGLES, 0, 6); - } - raf = requestAnimationFrame(frame); - } - raf = requestAnimationFrame(frame); - - return () => { - cancelAnimationFrame(raf); - ro.disconnect(); - try { gl.getExtension('WEBGL_lose_context')?.loseContext(); } catch (_) {} - }; - }, [color, pixelSize, speed, edgeFade]); + // 8x the pixel size matches the old shader's cell size; the dot grid reads as the + // same diffuse pixel haze. Slower speed -> longer, calmer pulse. + const tile = Math.max(4, pixelSize * 2); + const dur = `${Math.max(2, 3 / Math.max(speed, 0.1))}s`; + const fadePct = Math.round((1 - Math.min(Math.max(edgeFade, 0), 0.5)) * 100); return ( - + <> + +
+ ); }; diff --git a/frontend/src/app/pages/Views/ViewEditor.tsx b/frontend/src/app/pages/Views/ViewEditor.tsx index eb49b235..add0938b 100644 --- a/frontend/src/app/pages/Views/ViewEditor.tsx +++ b/frontend/src/app/pages/Views/ViewEditor.tsx @@ -906,7 +906,7 @@ const ViewEditor: React.FC = ({ output }) => { return () => window.clearTimeout(t); }, [captureAppThumbnail]); - // Keep PixelBlast mounted across transient gate flips; unmounting rebuilds the GL context and the user reads it as the animation restarting. + // Keep the placeholder mounted across transient gate flips so the loading animation doesn't flicker/restart. const placeholderVisible = showInstallPlaceholder || !iframePainted; const [placeholderMounted, setPlaceholderMounted] = useState(placeholderVisible); useEffect(() => { @@ -1261,7 +1261,7 @@ const ViewEditor: React.FC = ({ output }) => { onContentLoad={onIframeContentLoad} /> )} - {/* previewSettled gate: PixelBlast spins up a WebGL2 context, and spinning one up per fast-switched-past app churns GL contexts faster than the renderer can recycle them, which crashes the host renderer (and the whole app) under app-list spam. Same 250ms gate as the webview. */} + {/* previewSettled gate: skip mounting the preview webview for apps the user switches past faster than 250ms, so blowing through the app list never spawns a pile of webview renderers. (The loading placeholder is CSS now, so it no longer churns GL contexts.) */} {previewSettled && placeholderMounted && (