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);
};
}