Files
openswarm/electron/splash/splash.html
T

500 lines
22 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'self' data: 'unsafe-inline'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src data:;">
<title>OpenSwarm</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%; height: 100%; overflow: hidden;
background: #0a0a10;
}
body {
-webkit-app-region: drag;
color: #e8e8f4;
font-family: -apple-system, "Segoe UI", system-ui, sans-serif;
user-select: none;
cursor: default;
}
#stage {
position: relative;
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
}
canvas#bg {
position: absolute; inset: 0;
width: 100%; height: 100%;
image-rendering: pixelated;
image-rendering: crisp-edges;
-ms-interpolation-mode: nearest-neighbor;
}
.center {
position: relative; z-index: 1;
display: flex; flex-direction: column;
align-items: center; gap: 14px;
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 <img> 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: 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;
color: #f0f0fa;
text-shadow: 0 0 12px rgba(0,0,0,0.6);
opacity: 0;
animation: fadeIn 700ms ease forwards 220ms;
}
.status {
font-size: 11.5px; font-weight: 500;
color: rgba(232, 232, 244, 0.7);
text-align: center; min-height: 16px;
letter-spacing: 0.05em;
text-shadow: 0 0 8px rgba(0,0,0,0.7);
transition: color 240ms ease;
opacity: 0;
animation: fadeIn 700ms ease forwards 360ms;
max-width: 380px;
}
.status.warning { color: rgba(255, 198, 110, 0.92); }
.status.error { color: rgba(255, 118, 118, 0.95); }
.actions {
display: none; gap: 8px; margin-top: 4px;
}
.actions.visible { display: flex; }
button {
-webkit-app-region: no-drag;
background: rgba(140, 100, 220, 0.16);
border: 1px solid rgba(170, 130, 240, 0.45);
color: #e8e8f4;
padding: 6px 14px; border-radius: 4px;
font-family: inherit;
font-size: 10.5px; font-weight: 600;
letter-spacing: 0.08em; text-transform: uppercase;
cursor: pointer;
transition: background 180ms ease, border-color 180ms ease;
}
button:hover {
background: rgba(170, 130, 240, 0.30);
border-color: rgba(190, 150, 255, 0.7);
}
button:active { transform: translateY(1px); }
pre.logs {
-webkit-app-region: no-drag;
display: none;
background: rgba(0, 0, 0, 0.55);
border: 1px solid rgba(255, 118, 118, 0.4);
color: #e8e8f4;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 9px; line-height: 1.45;
padding: 6px 8px;
width: 360px; max-height: 70px;
overflow: auto; white-space: pre-wrap; word-break: break-all;
border-radius: 3px;
user-select: text; cursor: text;
}
pre.logs.visible { display: block; }
@keyframes fadeIn {
from { opacity: 0; transform: translateY(2px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
</head>
<body>
<div id="stage">
<canvas id="bg"></canvas>
<div class="center">
<img class="logo" src="__OPENSWARM_LOGO__" alt="">
<div class="name">OpenSwarm</div>
<div class="status" id="status">Starting&hellip;</div>
<div class="actions" id="actions">
<button id="logsBtn">View logs</button>
<button id="restartBtn">Restart</button>
<button id="quitBtn">Quit</button>
</div>
<pre class="logs" id="logsPanel"></pre>
</div>
</div>
<script>
(function () {
'use strict';
const { ipcRenderer } = require('electron');
// -----------------------------------------------------------------
// Dither shader background. WebGL-first; falls back to a solid dark
// panel if WebGL init fails (very old GPUs / locked-down enterprise
// VMs). Logo + text remain visible either way.
// -----------------------------------------------------------------
const canvas = document.getElementById('bg');
let renderFrame = function () {}; // no-op until WebGL initializes
function resizeCanvas() {
const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));
const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w; canvas.height = h;
}
}
function initWebGL() {
const gl = canvas.getContext('webgl', { antialias: false, alpha: false, premultipliedAlpha: false });
if (!gl) throw new Error('webgl unavailable');
const vsSrc = [
'attribute vec2 a_pos;',
'void main() { gl_Position = vec4(a_pos, 0.0, 1.0); }'
].join('\n');
// 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 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 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');
function compile(type, src) {
const sh = gl.createShader(type);
gl.shaderSource(sh, src);
gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
throw new Error('shader: ' + gl.getShaderInfoLog(sh));
}
return sh;
}
const vs = compile(gl.VERTEX_SHADER, vsSrc);
const fs = compile(gl.FRAGMENT_SHADER, fsSrc);
const prog = gl.createProgram();
gl.attachShader(prog, vs); gl.attachShader(prog, fs);
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
throw new Error('link: ' + gl.getProgramInfoLog(prog));
}
gl.useProgram(prog);
// Fullscreen quad (two triangles).
const quad = new Float32Array([-1,-1, 1,-1, -1,1, -1,1, 1,-1, 1,1]);
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, quad, gl.STATIC_DRAW);
const aPos = gl.getAttribLocation(prog, 'a_pos');
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
// ------ 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.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
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 <img> 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)
// <img> 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);
};
}
try { initWebGL(); } catch (e) {
console.warn('[splash] WebGL fallback:', e && e.message);
}
let running = true;
const start = performance.now();
function loop() {
if (!running) return;
renderFrame((performance.now() - start) * 0.001);
requestAnimationFrame(loop);
}
resizeCanvas();
requestAnimationFrame(loop);
// Free the GPU once main window takes over so we don't burn cycles
// during the user's workflow.
window.addEventListener('beforeunload', function () { running = false; });
document.addEventListener('visibilitychange', function () {
if (document.hidden) running = false;
});
// -----------------------------------------------------------------
// Status updates from main process. payload is either a plain string
// (info) or { text, level, logs?, showActions? }.
// -----------------------------------------------------------------
const statusEl = document.getElementById('status');
const actionsEl = document.getElementById('actions');
const logsPanelEl = document.getElementById('logsPanel');
// Once the action buttons have appeared (long-running boot or hard
// failure), keep them visible across subsequent status updates —
// hiding them would yank a target the user is reading/about to click.
let actionsLatched = false;
ipcRenderer.on('splash:status', function (_e, payload) {
if (typeof payload === 'string') {
statusEl.textContent = payload;
statusEl.className = 'status';
return;
}
const text = (payload && payload.text) || '';
const level = (payload && payload.level) || 'info';
statusEl.textContent = text;
statusEl.className = 'status' + (level !== 'info' ? ' ' + level : '');
if (payload && payload.showActions) {
actionsLatched = true;
actionsEl.classList.add('visible');
} else if (!actionsLatched) {
actionsEl.classList.remove('visible');
}
if (payload && payload.logs) {
logsPanelEl.textContent = payload.logs;
logsPanelEl.classList.add('visible');
} else if (!actionsLatched) {
logsPanelEl.classList.remove('visible');
}
});
document.getElementById('logsBtn').addEventListener('click', function () {
ipcRenderer.send('splash:action', 'open-logs');
});
document.getElementById('restartBtn').addEventListener('click', function () {
ipcRenderer.send('splash:action', 'restart');
});
document.getElementById('quitBtn').addEventListener('click', function () {
ipcRenderer.send('splash:action', 'quit');
});
})();
</script>
</body>
</html>