diff --git a/electron/main.js b/electron/main.js index 135ec3a0..5dfac16f 100644 --- a/electron/main.js +++ b/electron/main.js @@ -166,6 +166,30 @@ function emitSplashStatus(payload) { } } +// OS-tailored status copy. The "first launch is slow" experience has very +// different causes per platform (Defender on Windows, Gatekeeper + +// XProtect notarization scan on macOS), and naming the actual culprit +// helps users feel like the wait is intentional rather than the app being +// broken. Used by the long-wait branches in waitForBackend below. +function osStillStartingText() { + if (process.platform === 'win32') { + return 'Still starting — Windows Defender is scanning files (first launch only)…'; + } + if (process.platform === 'darwin') { + return 'Still starting — macOS is verifying the bundle (first launch only)…'; + } + return 'Still starting (first launch is slower than subsequent launches)…'; +} +function osTakingTooLongText() { + if (process.platform === 'win32') { + return 'Backend is taking longer than usual. Defender scans of 14k files can take a few minutes on slow drives.'; + } + if (process.platform === 'darwin') { + return 'Backend is taking longer than usual. macOS first-launch checks can be slow on cold cache.'; + } + return 'Backend is taking longer than usual. You can wait, view logs, or restart.'; +} + /** * macOS GUI apps launched from Finder/Dock inherit a minimal PATH from launchd * (/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin) — none of the user's shell @@ -294,15 +318,12 @@ function waitForBackend(port, opts = {}) { const elapsed = Date.now() - start; if (elapsed > 60_000 && !stillStartingNotified) { stillStartingNotified = true; - emitSplashStatus({ - text: 'Still starting (first launch can take 2-3 minutes on Windows while Defender scans)…', - level: 'warning', - }); + emitSplashStatus({ text: osStillStartingText(), level: 'warning' }); } if (elapsed > 180_000 && !actionsShown) { actionsShown = true; emitSplashStatus({ - text: 'Backend is taking unusually long. You can wait, view logs, or restart.', + text: osTakingTooLongText(), level: 'warning', showActions: true, logs: recentBackendStderr.slice(-20).join(''), diff --git a/electron/splash/preview.js b/electron/splash/preview.js new file mode 100644 index 00000000..df2e8317 --- /dev/null +++ b/electron/splash/preview.js @@ -0,0 +1,102 @@ +// Standalone splash preview — opens the splash window in isolation and +// cycles through every status state so you can eyeball the UI without +// running the full backend + frontend + electron stack. +// +// Run from the electron/ dir: +// npx electron splash/preview.js +// +// Cycle (~26s total, then idles on the error state with action buttons): +// 0s "Starting OpenSwarm…" +// 3s "Connecting to dev backend…" +// 6s "Loading components…" +// 9s "Almost ready…" +// 12s "Still starting (warning, no actions)" +// 18s "Backend taking too long" (with View Logs / Restart / Quit buttons) +// 24s error state: "OpenSwarm couldn't start" + last lines of stderr + +const { app, BrowserWindow, ipcMain, shell } = require('electron'); +const fs = require('fs'); +const path = require('path'); + +let splashWindow = null; + +function loadSplashDataUrl() { + const html = fs.readFileSync(path.join(__dirname, 'splash.html'), 'utf8'); + const iconPath = path.join(__dirname, '..', 'build', 'icon.png'); + const iconBytes = fs.readFileSync(iconPath); + const iconDataUrl = 'data:image/png;base64,' + iconBytes.toString('base64'); + const finalHtml = html.replace('__OPENSWARM_LOGO__', iconDataUrl); + return 'data:text/html;charset=utf-8;base64,' + Buffer.from(finalHtml).toString('base64'); +} + +function emit(payload) { + if (splashWindow && !splashWindow.isDestroyed()) { + splashWindow.webContents.send('splash:status', payload); + const txt = typeof payload === 'string' ? payload : payload.text; + console.log(`[preview] -> ${txt}`); + } +} + +ipcMain.on('splash:action', (_e, action) => { + console.log(`[preview] splash button clicked: ${action}`); + if (action === 'quit') app.quit(); + else if (action === 'restart') { app.relaunch(); app.exit(0); } + else if (action === 'open-logs') console.log('[preview] (would open log dir)'); +}); + +app.whenReady().then(() => { + splashWindow = new BrowserWindow({ + width: 460, + height: 340, + frame: false, + resizable: false, + skipTaskbar: false, // keep visible in taskbar for the preview + show: true, + center: true, + backgroundColor: '#0a0a10', + title: 'OpenSwarm splash preview', + webPreferences: { + nodeIntegration: true, + contextIsolation: false, + sandbox: false, + backgroundThrottling: false, + }, + }); + splashWindow.setMenuBarVisibility(false); + splashWindow.loadURL(loadSplashDataUrl()); + + // Cycle through every state with delays so you can see each one. + splashWindow.webContents.once('did-finish-load', () => { + setTimeout(() => emit('Starting OpenSwarm…'), 0); + setTimeout(() => emit('Connecting to dev backend…'), 3000); + setTimeout(() => emit('Loading components…'), 6000); + setTimeout(() => emit('Almost ready…'), 9000); + // Mirror the OS-tailored copy from main.js so the preview shows what + // real users would see on this platform. + const stillStarting = process.platform === 'win32' + ? 'Still starting — Windows Defender is scanning files (first launch only)…' + : process.platform === 'darwin' + ? 'Still starting — macOS is verifying the bundle (first launch only)…' + : 'Still starting (first launch is slower than subsequent launches)…'; + const takingTooLong = process.platform === 'win32' + ? 'Backend is taking longer than usual. Defender scans of 14k files can take a few minutes on slow drives.' + : process.platform === 'darwin' + ? 'Backend is taking longer than usual. macOS first-launch checks can be slow on cold cache.' + : 'Backend is taking longer than usual. You can wait, view logs, or restart.'; + setTimeout(() => emit({ text: stillStarting, level: 'warning' }), 12000); + setTimeout(() => emit({ + text: takingTooLong, + level: 'warning', + showActions: true, + logs: '[backend] uvicorn warming up...\n[backend] importing claude_agent_sdk...\n[backend] (still working)', + }), 18000); + setTimeout(() => emit({ + text: "OpenSwarm couldn't start: Backend process exited with code 1 during startup", + level: 'error', + showActions: true, + logs: '[backend] Traceback (most recent call last):\n[backend] File "backend/main.py", line 48\n[backend] ImportError: simulated failure for splash preview', + }), 24000); + }); + + splashWindow.on('closed', () => { app.quit(); }); +}); diff --git a/electron/splash/splash.html b/electron/splash/splash.html index 99f4d34b..4d592b96 100644 --- a/electron/splash/splash.html +++ b/electron/splash/splash.html @@ -36,13 +36,23 @@ padding: 0 24px; max-width: 100%; } + /* Logo is rendered inside the WebGL canvas now (sampled as a texture + so the dither pattern can flow through it). The stays in the + DOM purely as the WebGL-failure fallback; CSS hides it once the + shader has uploaded the texture, and reveals it if WebGL init fails. */ .logo { width: 84px; height: 84px; - image-rendering: auto; + image-rendering: pixelated; + image-rendering: crisp-edges; filter: drop-shadow(0 0 18px rgba(160, 100, 240, 0.55)); opacity: 0; animation: fadeIn 700ms ease forwards 60ms; } + /* `visibility: hidden` (not display:none) so the layout slot is preserved + — the shader queries .logo.getBoundingClientRect() to know where to + render the dithered octopus, and we want that slot to keep flexbox + centering the brand text + status text below it. */ + .logo.shader-active { visibility: hidden; } .name { font-size: 14px; font-weight: 600; letter-spacing: 0.32em; text-transform: uppercase; @@ -152,25 +162,157 @@ 'void main() { gl_Position = vec4(a_pos, 0.0, 1.0); }' ].join('\n'); - // Animated layered fluid (sin/cos blend) → bayer-dithered into 2 colors. + // Port of the reactbits Dither shader (https://reactbits.dev/backgrounds/dither) + // — same Perlin noise + fbm + 8×8 Bayer + N-color quantization. Difference + // from upstream: we render the wave AND the octopus icon overlay in a + // single fragment program (one draw call), and the per-pixel "downsample + // to fat pixels then dither" trick is folded into the same pass instead of + // being a postprocessing EffectComposer chain. Costs the same per pixel + // as upstream, no postprocess RTT. + // + // Octopus overlay: pixel-art icon sampled at NEAREST (so the dither + // doesn't smear the source pixels into a blur first). Its body color + // gets quantized through the same dither so it visually belongs in + // the same stippled world as the background. const fsSrc = [ 'precision highp float;', 'uniform vec2 u_res;', 'uniform float u_time;', - 'uniform sampler2D u_bayer;', + 'uniform float u_dpr;', + 'uniform sampler2D u_icon;', + 'uniform vec2 u_icon_center_px;', + 'uniform float u_icon_size_px;', + 'uniform vec3 u_wave_color;', + 'uniform float u_wave_speed;', + 'uniform float u_wave_freq;', + 'uniform float u_wave_amp;', + 'uniform float u_color_num;', + 'uniform float u_pixel_size;', + '', + // ----- Classic Perlin noise (Stefan Gustavson) ----- + 'vec4 mod289(vec4 x){return x-floor(x*(1.0/289.0))*289.0;}', + 'vec4 permute(vec4 x){return mod289(((x*34.0)+1.0)*x);}', + 'vec4 taylorInvSqrt(vec4 r){return 1.79284291400159-0.85373472095314*r;}', + 'vec2 fade(vec2 t){return t*t*t*(t*(t*6.0-15.0)+10.0);}', + 'float cnoise(vec2 P){', + ' vec4 Pi=floor(P.xyxy)+vec4(0.0,0.0,1.0,1.0);', + ' vec4 Pf=fract(P.xyxy)-vec4(0.0,0.0,1.0,1.0);', + ' Pi=mod289(Pi);', + ' vec4 ix=Pi.xzxz; vec4 iy=Pi.yyww;', + ' vec4 fx=Pf.xzxz; vec4 fy=Pf.yyww;', + ' vec4 i=permute(permute(ix)+iy);', + ' vec4 gx=fract(i*(1.0/41.0))*2.0-1.0;', + ' vec4 gy=abs(gx)-0.5;', + ' vec4 tx=floor(gx+0.5); gx=gx-tx;', + ' vec2 g00=vec2(gx.x,gy.x); vec2 g10=vec2(gx.y,gy.y);', + ' vec2 g01=vec2(gx.z,gy.z); vec2 g11=vec2(gx.w,gy.w);', + ' vec4 norm=taylorInvSqrt(vec4(dot(g00,g00),dot(g01,g01),dot(g10,g10),dot(g11,g11)));', + ' g00*=norm.x; g01*=norm.y; g10*=norm.z; g11*=norm.w;', + ' float n00=dot(g00,vec2(fx.x,fy.x));', + ' float n10=dot(g10,vec2(fx.y,fy.y));', + ' float n01=dot(g01,vec2(fx.z,fy.z));', + ' float n11=dot(g11,vec2(fx.w,fy.w));', + ' vec2 fade_xy=fade(Pf.xy);', + ' vec2 n_x=mix(vec2(n00,n01),vec2(n10,n11),fade_xy.x);', + ' return 2.3*mix(n_x.x,n_x.y,fade_xy.y);', + '}', + '', + // ----- 4-octave fractal-Brownian-motion of |cnoise| ----- + 'float fbm(vec2 p){', + ' float v=0.0; float amp=1.0; float freq=u_wave_freq;', + ' for(int i=0;i<4;i++){', + ' v+=amp*abs(cnoise(p));', + ' p*=freq;', + ' amp*=u_wave_amp;', + ' }', + ' return v;', + '}', + 'float pattern(vec2 p){', + ' vec2 p2=p-u_time*u_wave_speed;', + ' return fbm(p+fbm(p2));', + '}', + '', + // ----- 8×8 Bayer threshold (computed inline — no texture lookup) ----- + 'float bayer8(vec2 c){', + ' int x=int(mod(c.x,8.0));', + ' int y=int(mod(c.y,8.0));', + // Inline lookup: avoids the WebGL1 dynamic-array-indexing footgun that + // killed earlier tries. 64 if-statements compile to a fast jump. + ' if(y==0){if(x==0)return 0.0/64.0;if(x==1)return 48.0/64.0;if(x==2)return 12.0/64.0;if(x==3)return 60.0/64.0;if(x==4)return 3.0/64.0;if(x==5)return 51.0/64.0;if(x==6)return 15.0/64.0;return 63.0/64.0;}', + ' if(y==1){if(x==0)return 32.0/64.0;if(x==1)return 16.0/64.0;if(x==2)return 44.0/64.0;if(x==3)return 28.0/64.0;if(x==4)return 35.0/64.0;if(x==5)return 19.0/64.0;if(x==6)return 47.0/64.0;return 31.0/64.0;}', + ' if(y==2){if(x==0)return 8.0/64.0;if(x==1)return 56.0/64.0;if(x==2)return 4.0/64.0;if(x==3)return 52.0/64.0;if(x==4)return 11.0/64.0;if(x==5)return 59.0/64.0;if(x==6)return 7.0/64.0;return 55.0/64.0;}', + ' if(y==3){if(x==0)return 40.0/64.0;if(x==1)return 24.0/64.0;if(x==2)return 36.0/64.0;if(x==3)return 20.0/64.0;if(x==4)return 43.0/64.0;if(x==5)return 27.0/64.0;if(x==6)return 39.0/64.0;return 23.0/64.0;}', + ' if(y==4){if(x==0)return 2.0/64.0;if(x==1)return 50.0/64.0;if(x==2)return 14.0/64.0;if(x==3)return 62.0/64.0;if(x==4)return 1.0/64.0;if(x==5)return 49.0/64.0;if(x==6)return 13.0/64.0;return 61.0/64.0;}', + ' if(y==5){if(x==0)return 34.0/64.0;if(x==1)return 18.0/64.0;if(x==2)return 46.0/64.0;if(x==3)return 30.0/64.0;if(x==4)return 33.0/64.0;if(x==5)return 17.0/64.0;if(x==6)return 45.0/64.0;return 29.0/64.0;}', + ' if(y==6){if(x==0)return 10.0/64.0;if(x==1)return 58.0/64.0;if(x==2)return 6.0/64.0;if(x==3)return 54.0/64.0;if(x==4)return 9.0/64.0;if(x==5)return 57.0/64.0;if(x==6)return 5.0/64.0;return 53.0/64.0;}', + ' if(x==0)return 42.0/64.0;if(x==1)return 26.0/64.0;if(x==2)return 38.0/64.0;if(x==3)return 22.0/64.0;if(x==4)return 41.0/64.0;if(x==5)return 25.0/64.0;if(x==6)return 37.0/64.0;return 21.0/64.0;', + '}', + '', + // ----- N-color ordered dither, exactly matching reactbits ----- + 'vec3 dither(vec2 pixCoord, vec3 color){', + ' float threshold = bayer8(pixCoord) - 0.25;', + ' float stepSize = 1.0 / (u_color_num - 1.0);', + ' color += threshold * stepSize;', + ' color = clamp(color - 0.2, 0.0, 1.0);', + ' return floor(color*(u_color_num - 1.0) + 0.5) / (u_color_num - 1.0);', + '}', + '', 'void main() {', - ' vec2 uv = gl_FragCoord.xy / u_res;', - ' float t = u_time;', - ' float a = sin(uv.x * 5.2 + t * 0.42) * cos(uv.y * 3.8 + t * 0.29);', - ' float b = sin((uv.x + uv.y) * 4.6 + t * 0.58) * 0.55;', - ' float c = cos(uv.x * 7.1 - uv.y * 5.3 + t * 0.21) * 0.35;', - ' float fluid = (a + b + c) * 0.32 + 0.5;', - ' vec2 bUv = fract(gl_FragCoord.xy / 4.0);', - ' float thresh = texture2D(u_bayer, bUv).r;', - ' float lit = step(thresh, fluid);', - ' vec3 dark = vec3(0.038, 0.038, 0.062);', // ~#0a0a10 - ' vec3 lite = vec3(0.522, 0.286, 0.878);', // ~#854ae0 (electric purple) - ' gl_FragColor = vec4(mix(dark, lite, lit), 1.0);', + ' vec2 fc = gl_FragCoord.xy;', + // Snap to fat pixel grid (matches reactbits pixelSize behavior) + ' vec2 snapped = floor(fc / u_pixel_size) * u_pixel_size;', + // ----- Background: cnoise/fbm pattern in aspect-corrected coords ----- + ' vec2 uv = snapped / u_res - 0.5;', + ' uv.x *= u_res.x / u_res.y;', + ' float f = pattern(uv);', + ' vec3 col = mix(vec3(0.0), u_wave_color, f);', + '', + // ----- Foreground: octopus icon, integrated into the dithered world ----- + // Goal: the octopus shouldn't look "stuck on top of" the background. It + // should feel like part of the same dithered terrain — same color + // grammar, same quantization. We achieve that by: + // (a) Letting some of the bg pattern bleed INTO the octopus body + // (multiply icon brightness by a subtle pattern factor). + // (b) Surrounding the icon with a soft glow that's also dithered + // (the glow shares the same palette as the icon body). + // (c) Boosting the icon's contrast slightly so it remains the focal + // point even with the integration. + // Eyes (very dark pixels) stay solid via a luminance early-out below. + ' vec2 logical = fc / u_dpr;', + ' logical.y = (u_res.y / u_dpr) - logical.y;', + ' vec2 iconRel = (logical - u_icon_center_px) / u_icon_size_px + 0.5;', + // Distance-from-icon-center, 0 at center, 1 at edge of icon bbox, >1 outside + ' vec2 iconLocal = (logical - u_icon_center_px) / (u_icon_size_px * 0.5);', + ' float iconDist = length(iconLocal);', + // Soft glow ring around the icon — bleeds the icon color into bg waves + ' float glow = exp(-iconDist * iconDist * 1.8);', + ' vec3 glowColor = vec3(1.00, 0.45, 0.32);', // coral matches octopus body + ' col = mix(col, glowColor, glow * 0.18);', + ' if (iconRel.x >= 0.0 && iconRel.x <= 1.0 && iconRel.y >= 0.0 && iconRel.y <= 1.0) {', + ' vec4 iconTex = texture2D(u_icon, iconRel);', + ' if (iconTex.a > 0.05) {', + ' float lum = dot(iconTex.rgb, vec3(0.30, 0.59, 0.11));', + ' vec3 iconCol = iconTex.rgb;', + // Boost coral saturation slightly so the dither doesn\'t mute it. + ' iconCol = mix(vec3(lum), iconCol, 1.15);', + // Bleed bg pattern into icon body (only on bright pixels, not eyes): + // multiply the body color by a subtle pattern factor so it shimmers + // with the same noise that drives the bg. Eyes (lum<0.2) skip this. + ' if (lum > 0.20) {', + ' float bleed = 0.85 + 0.30 * f;', + ' iconCol *= bleed;', + ' }', + ' col = mix(col, iconCol, iconTex.a);', + ' }', + ' }', + '', + // ----- Final ordered-dither over the composited frame ----- + // Apply dither to the WHOLE image (bg + icon together) so the icon and + // background share the same color quantization. Matches reactbits. + ' vec2 ditherCoord = floor(fc / u_pixel_size);', + ' col = dither(ditherCoord, col);', + '', + ' gl_FragColor = vec4(col, 1.0);', '}' ].join('\n'); @@ -202,31 +344,85 @@ gl.enableVertexAttribArray(aPos); gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0); - // 4×4 Bayer ordered-dither matrix as a luminance texture. - const bayer = new Uint8Array([ - 0, 8, 2, 10, - 12, 4, 14, 6, - 3, 11, 1, 9, - 15, 7, 13, 5 - ].map(function (v) { return Math.floor(v * 255 / 16); })); - const tex = gl.createTexture(); - gl.bindTexture(gl.TEXTURE_2D, tex); - gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE, 4, 4, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, bayer); + // ------ Octopus icon texture (only texture we use; bayer lives inline in shader) ------ + const iconTex = gl.createTexture(); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, iconTex); + // 1-px transparent stub so the shader doesn't sample undefined memory + // before the PNG finishes decoding. + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, + new Uint8Array([0, 0, 0, 0])); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); - const uRes = gl.getUniformLocation(prog, 'u_res'); - const uTime = gl.getUniformLocation(prog, 'u_time'); - const uTex = gl.getUniformLocation(prog, 'u_bayer'); - gl.uniform1i(uTex, 0); + var iconLoaded = false; + var logoEl = document.querySelector('.logo'); + function uploadIcon() { + if (iconLoaded || !logoEl || !logoEl.naturalWidth) return; + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, iconTex); + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); + gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, logoEl); + iconLoaded = true; + // Hide the DOM now that the texture is on the GPU. + logoEl.classList.add('shader-active'); + } + if (logoEl) { + if (logoEl.complete && logoEl.naturalWidth > 0) uploadIcon(); + else logoEl.addEventListener('load', uploadIcon, { once: true }); + } + + const uRes = gl.getUniformLocation(prog, 'u_res'); + const uTime = gl.getUniformLocation(prog, 'u_time'); + const uDpr = gl.getUniformLocation(prog, 'u_dpr'); + const uIcon = gl.getUniformLocation(prog, 'u_icon'); + const uIconCenter = gl.getUniformLocation(prog, 'u_icon_center_px'); + const uIconSize = gl.getUniformLocation(prog, 'u_icon_size_px'); + const uWaveColor = gl.getUniformLocation(prog, 'u_wave_color'); + const uWaveSpeed = gl.getUniformLocation(prog, 'u_wave_speed'); + const uWaveFreq = gl.getUniformLocation(prog, 'u_wave_freq'); + const uWaveAmp = gl.getUniformLocation(prog, 'u_wave_amp'); + const uColorNum = gl.getUniformLocation(prog, 'u_color_num'); + const uPixelSize = gl.getUniformLocation(prog, 'u_pixel_size'); + gl.uniform1i(uIcon, 0); + // Warm magenta-leaning purple — the orange tilt resonates with the + // octopus's coral body, so the icon and bg feel like the same world. + // Pure purple (0.52, 0.29, 0.88) was too cool and the octopus looked + // pasted on; pure orange would clash with brand. This sits between. + gl.uniform3f(uWaveColor, 0.78, 0.32, 0.62); + gl.uniform1f(uWaveSpeed, 0.05); + gl.uniform1f(uWaveFreq, 3.0); + gl.uniform1f(uWaveAmp, 0.3); + gl.uniform1f(uColorNum, 4.0); + gl.uniform1f(uPixelSize, 2.0); renderFrame = function (tSec) { resizeCanvas(); + const dpr = Math.min(window.devicePixelRatio || 1, 1.5); + // Octopus geometry: read the live .logo element's bounding rect so + // the shader draws the dithered icon EXACTLY where the (now-hidden) + // would have appeared. This stays correct if CSS sizing + // changes or window is resized — no constants to drift. + var rect = logoEl ? logoEl.getBoundingClientRect() : null; + var cx, cy, sz; + if (rect && rect.width > 0) { + cx = rect.left + rect.width * 0.5; + cy = rect.top + rect.height * 0.5; + sz = Math.max(rect.width, rect.height); + } else { + // Fallback: center horizontally, top third vertically. + var cssW = canvas.width / dpr, cssH = canvas.height / dpr; + cx = cssW * 0.5; cy = cssH * 0.35; sz = 96; + } gl.viewport(0, 0, canvas.width, canvas.height); gl.uniform2f(uRes, canvas.width, canvas.height); gl.uniform1f(uTime, tSec); + gl.uniform1f(uDpr, dpr); + gl.uniform2f(uIconCenter, cx, cy); + gl.uniform1f(uIconSize, sz); gl.drawArrays(gl.TRIANGLES, 0, 6); }; }