Findings from a full frontend audit (static read + live instrumentation of
production). This covers the "bleeding" and performance phases.
Bug fixes:
- StatsPanel branch collapse/expand never re-rendered. Two Svelte 5 defects:
$state(new Set()) is not proxied (proxy.js bails on non-object/array
prototypes), so .add()/.delete() were untracked; and the Svelte 4
`x = x` reactivity idiom is a no-op in runes mode, where sources use a
strict `equals` comparator. Switched to SvelteSet and dropped the
self-assignment. Verified: aria-expanded now flips and rows 28<->24.
- The closed stats panel's gold box-shadow (8px offset + 80px blur) bled
~88px past its right edge, painting a permanent olive stripe down the
left of the viewport on every page view. Offset the closed transform
past the shadow.
- data.ts shipped a placeholder repoUrl (github.com/yourusername/...), a
live 404 on the node describing this very site. Also removed the
"Tailwind CSS" techStack claim; it is not a dependency of this project.
Performance:
- nodes.ts awaited Assets.load() inside the node loop, serialising ~56 icon
round-trips before the tree could render or accept input. Now one
parallel batch. Measured: all 78 tree icon requests start at ~28ms and
complete within ~618ms.
- isLoading was cleared right after app.init(), before a single texture
existed, so the spinner resolved onto an empty screen. Moved to after
the scene graph is built.
- Renderer left antialias on (costly, ~no benefit on soft glow circles)
and resolution/autoDensity at defaults (blurry on HiDPI). Now
antialias:false, resolution:min(dpr,2), autoDensity, high-performance.
- cacheAsTexture on the fully-static gradient (16 full-screen fills) and
vignette (13); interactiveChildren:false on background containers.
- Dust ticker allocated a fresh closure per frame via forEach; replaced
with a named function and an indexed loop.
Dead weight:
- static/{headshot,profile}.jpg.original (8.6MB, 83% of the static payload)
were committed and deployed verbatim by adapter-static, publicly
fetchable and referenced by nothing. Moved to a gitignored assets-src/
so the full-res sources stay on disk without shipping.
- precompress:true so nginx gzip_static/brotli_static have siblings to
serve (the origin currently sends everything uncompressed).
build/ 11.13MB -> 3.7MB. JS wire 727.6KB raw -> 232KB gzip / 202.7KB brotli.
svelte-check clean, 14/14 tests pass.
Note: the matching nginx change lives in the gitignored homelab/default.conf
and must be applied to the container separately.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
254 lines
7.9 KiB
TypeScript
254 lines
7.9 KiB
TypeScript
// Multi-layer space background: gradient → nebula → stars → dust → vignette.
|
|
// Each layer is added to the stage here; the three parallax containers are
|
|
// returned so the interaction module can offset them while panning.
|
|
import { Application, Graphics, Container } from 'pixi.js';
|
|
import { gsap } from 'gsap';
|
|
import { nebulaPalette } from '$lib/theme';
|
|
import { PARTICLE_COUNTS, byDevice } from '$lib/config';
|
|
import type { BackgroundLayers } from './types';
|
|
|
|
export function createBackground(app: Application, isMobile: boolean): BackgroundLayers {
|
|
const deepBackgroundContainer = new Container();
|
|
const nebulaContainer = new Container();
|
|
const starContainer = new Container();
|
|
|
|
app.stage.addChild(deepBackgroundContainer);
|
|
app.stage.addChild(nebulaContainer);
|
|
app.stage.addChild(starContainer);
|
|
|
|
const screenWidth = app.screen.width;
|
|
const screenHeight = app.screen.height;
|
|
const centerX = screenWidth / 2;
|
|
const centerY = screenHeight / 2;
|
|
|
|
// ========== DEEP SPACE GRADIENT ==========
|
|
const spaceGradient = new Graphics();
|
|
const gradientRadius = Math.max(screenWidth, screenHeight);
|
|
|
|
const gradientSteps = 15;
|
|
for (let i = gradientSteps; i >= 0; i--) {
|
|
const ratio = i / gradientSteps;
|
|
const radius = gradientRadius * (0.3 + ratio * 0.7);
|
|
|
|
const r = Math.floor(8 * (1 - ratio));
|
|
const g = Math.floor(6 * (1 - ratio));
|
|
const b = Math.floor(20 * (1 - ratio));
|
|
const color = (r << 16) + (g << 8) + b;
|
|
|
|
spaceGradient.circle(centerX, centerY, radius).fill({ color, alpha: 0.8 });
|
|
}
|
|
deepBackgroundContainer.addChild(spaceGradient);
|
|
|
|
// ========== NEBULA CLOUDS ==========
|
|
const nebulaColors = nebulaPalette;
|
|
|
|
nebulaColors.forEach((nebula, index) => {
|
|
const angle = (index / nebulaColors.length) * Math.PI * 2 + Math.PI / 4;
|
|
const distance = Math.min(screenWidth, screenHeight) * 0.35;
|
|
const baseX = centerX + Math.cos(angle) * distance;
|
|
const baseY = centerY + Math.sin(angle) * distance;
|
|
|
|
const nebulaCloud = new Graphics();
|
|
|
|
// Outer very faint glow
|
|
const outerSize = 200 + Math.random() * 150;
|
|
for (let layer = 5; layer >= 0; layer--) {
|
|
const layerRatio = layer / 5;
|
|
const size = outerSize * (0.4 + layerRatio * 0.6);
|
|
const alpha = 0.02 * (1 - layerRatio);
|
|
nebulaCloud.circle(0, 0, size).fill({ color: nebula.color, alpha });
|
|
}
|
|
|
|
// Irregular blobs for a more organic look
|
|
for (let i = 0; i < 3; i++) {
|
|
const offsetX = (Math.random() - 0.5) * 100;
|
|
const offsetY = (Math.random() - 0.5) * 100;
|
|
const blobSize = 60 + Math.random() * 80;
|
|
|
|
for (let layer = 3; layer >= 0; layer--) {
|
|
const layerRatio = layer / 3;
|
|
const size = blobSize * (0.5 + layerRatio * 0.5);
|
|
const alpha = 0.03 * (1 - layerRatio);
|
|
nebulaCloud.circle(offsetX, offsetY, size).fill({ color: nebula.color, alpha });
|
|
}
|
|
}
|
|
|
|
nebulaCloud.x = baseX;
|
|
nebulaCloud.y = baseY;
|
|
nebulaContainer.addChild(nebulaCloud);
|
|
|
|
// Animate nebula pulsing
|
|
gsap.to(nebulaCloud, {
|
|
alpha: 0.6,
|
|
duration: 3 + Math.random() * 2,
|
|
ease: 'sine.inOut',
|
|
yoyo: true,
|
|
repeat: -1,
|
|
delay: Math.random() * 2
|
|
});
|
|
|
|
gsap.to(nebulaCloud.scale, {
|
|
x: 1.1,
|
|
y: 1.1,
|
|
duration: 4 + Math.random() * 2,
|
|
ease: 'sine.inOut',
|
|
yoyo: true,
|
|
repeat: -1,
|
|
delay: Math.random() * 2
|
|
});
|
|
});
|
|
|
|
// ========== DISTANT STAR FIELD ==========
|
|
// Layer 1: Tiny distant stars (many, dim)
|
|
const distantStarCount = byDevice(PARTICLE_COUNTS.distantStars, isMobile);
|
|
for (let i = 0; i < distantStarCount; i++) {
|
|
const star = new Graphics();
|
|
const size = Math.random() * 1 + 0.5;
|
|
const alpha = Math.random() * 0.4 + 0.1;
|
|
|
|
star.circle(0, 0, size).fill({ color: 0xffffff, alpha });
|
|
|
|
star.x = Math.random() * screenWidth;
|
|
star.y = Math.random() * screenHeight;
|
|
starContainer.addChild(star);
|
|
|
|
gsap.to(star, {
|
|
alpha: alpha * 0.3,
|
|
duration: 1 + Math.random() * 2,
|
|
ease: 'sine.inOut',
|
|
yoyo: true,
|
|
repeat: -1,
|
|
delay: Math.random() * 3
|
|
});
|
|
}
|
|
|
|
// Layer 2: Medium stars with colour tints
|
|
const mediumStarCount = byDevice(PARTICLE_COUNTS.mediumStars, isMobile);
|
|
const starTints = [0xffffff, 0xfff8e7, 0xe7f0ff, 0xffe7e7, 0xe7ffe7];
|
|
|
|
for (let i = 0; i < mediumStarCount; i++) {
|
|
const star = new Graphics();
|
|
const size = Math.random() * 1.5 + 1;
|
|
const alpha = Math.random() * 0.5 + 0.3;
|
|
const tint = starTints[Math.floor(Math.random() * starTints.length)];
|
|
|
|
star.circle(0, 0, size + 2).fill({ color: tint, alpha: alpha * 0.2 });
|
|
star.circle(0, 0, size).fill({ color: tint, alpha });
|
|
|
|
star.x = Math.random() * screenWidth;
|
|
star.y = Math.random() * screenHeight;
|
|
starContainer.addChild(star);
|
|
|
|
gsap.to(star, {
|
|
alpha: alpha * 0.4,
|
|
duration: 0.5 + Math.random() * 1.5,
|
|
ease: 'sine.inOut',
|
|
yoyo: true,
|
|
repeat: -1,
|
|
delay: Math.random() * 2
|
|
});
|
|
}
|
|
|
|
// Layer 3: Bright feature stars (few, prominent)
|
|
const brightStarCount = byDevice(PARTICLE_COUNTS.brightStars, isMobile);
|
|
for (let i = 0; i < brightStarCount; i++) {
|
|
const star = new Graphics();
|
|
const size = Math.random() * 2 + 1.5;
|
|
const tint = starTints[Math.floor(Math.random() * starTints.length)];
|
|
|
|
star.circle(0, 0, size + 6).fill({ color: tint, alpha: 0.05 });
|
|
star.circle(0, 0, size + 3).fill({ color: tint, alpha: 0.1 });
|
|
star.circle(0, 0, size).fill({ color: 0xffffff, alpha: 0.9 });
|
|
|
|
star.x = Math.random() * screenWidth;
|
|
star.y = Math.random() * screenHeight;
|
|
starContainer.addChild(star);
|
|
|
|
gsap.to(star.scale, {
|
|
x: 1.3,
|
|
y: 1.3,
|
|
duration: 2 + Math.random() * 2,
|
|
ease: 'sine.inOut',
|
|
yoyo: true,
|
|
repeat: -1,
|
|
delay: Math.random() * 2
|
|
});
|
|
}
|
|
|
|
// ========== FLOATING DUST PARTICLES ==========
|
|
const dustParticles: Array<{ graphics: Graphics; vx: number; vy: number }> = [];
|
|
const dustCount = byDevice(PARTICLE_COUNTS.dust, isMobile);
|
|
|
|
for (let i = 0; i < dustCount; i++) {
|
|
const dust = new Graphics();
|
|
const size = Math.random() * 2 + 1;
|
|
const colorIndex = Math.floor(Math.random() * nebulaColors.length);
|
|
const color = nebulaColors[colorIndex].color;
|
|
|
|
dust.circle(0, 0, size + 4).fill({ color, alpha: 0.05 });
|
|
dust.circle(0, 0, size + 2).fill({ color, alpha: 0.08 });
|
|
dust.circle(0, 0, size).fill({ color, alpha: 0.15 });
|
|
|
|
dust.x = Math.random() * screenWidth;
|
|
dust.y = Math.random() * screenHeight;
|
|
starContainer.addChild(dust);
|
|
|
|
dustParticles.push({
|
|
graphics: dust,
|
|
vx: (Math.random() - 0.5) * 0.2,
|
|
vy: (Math.random() - 0.5) * 0.2
|
|
});
|
|
|
|
gsap.to(dust, {
|
|
alpha: 0.3,
|
|
duration: 3 + Math.random() * 2,
|
|
ease: 'sine.inOut',
|
|
yoyo: true,
|
|
repeat: -1,
|
|
delay: Math.random() * 2
|
|
});
|
|
}
|
|
|
|
// Animate dust particles drifting. Named function + indexed loop: `forEach`
|
|
// with an inline arrow allocated a fresh closure on every frame.
|
|
function driftDust() {
|
|
for (let i = 0; i < dustParticles.length; i++) {
|
|
const p = dustParticles[i];
|
|
const g = p.graphics;
|
|
g.x += p.vx;
|
|
g.y += p.vy;
|
|
|
|
if (g.x < -20) g.x = screenWidth + 20;
|
|
else if (g.x > screenWidth + 20) g.x = -20;
|
|
if (g.y < -20) g.y = screenHeight + 20;
|
|
else if (g.y > screenHeight + 20) g.y = -20;
|
|
}
|
|
}
|
|
app.ticker.add(driftDust);
|
|
|
|
// ========== VIGNETTE OVERLAY ==========
|
|
const vignette = new Graphics();
|
|
const vignetteSize = Math.max(screenWidth, screenHeight) * 1.2;
|
|
|
|
for (let i = 12; i >= 0; i--) {
|
|
const ratio = i / 12;
|
|
const radius = vignetteSize * (0.4 + ratio * 0.6);
|
|
const alpha = ratio * ratio * 0.4;
|
|
vignette.circle(centerX, centerY, radius).fill({ color: 0x000000, alpha });
|
|
}
|
|
app.stage.addChild(vignette);
|
|
|
|
// The gradient (16 fills) and vignette (13 fills) are near-full-screen and
|
|
// never change — bake them to a texture instead of recompositing every frame.
|
|
deepBackgroundContainer.cacheAsTexture(true);
|
|
vignette.cacheAsTexture(true);
|
|
|
|
// Nothing in the background is clickable; skip it during hit-testing.
|
|
deepBackgroundContainer.interactiveChildren = false;
|
|
nebulaContainer.interactiveChildren = false;
|
|
starContainer.interactiveChildren = false;
|
|
vignette.eventMode = 'none';
|
|
|
|
return { deepBackgroundContainer, nebulaContainer, starContainer };
|
|
}
|