perf+fix: audit phase 0/1 — parallel textures, dead weight, two real bugs
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>
This commit is contained in:
1 parent
ad258f7f9f
commit
8874b35998
9 files changed
+68
-29
No files matched your search
@@ -27,3 +27,8 @@ Thumbs.db
|
|||||||
# Vite
|
# Vite
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
|
homelab/*
|
||||||
|
# Full-res source images — kept locally, never deployed (adapter-static copies all of static/)
|
||||||
|
assets-src/
|
||||||
|
*.original
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
import { Application, Container } from 'pixi.js';
|
import { Application, Container } from 'pixi.js';
|
||||||
import type { TreeNode } from '$lib/data';
|
import type { TreeNode } from '$lib/data';
|
||||||
import { categoryColorHex } from '$lib/theme';
|
import { categoryColorHex } from '$lib/theme';
|
||||||
@@ -45,7 +46,9 @@
|
|||||||
|
|
||||||
// Stats panel state
|
// Stats panel state
|
||||||
let statsPanelVisible = $state(false);
|
let statsPanelVisible = $state(false);
|
||||||
let expandedBranches = $state(new Set<string>(BRANCH_IDS)); // All expanded by default
|
// SvelteSet, not $state(new Set()) — Svelte 5 does not proxy a plain Set, so
|
||||||
|
// .add()/.delete() would be untracked and the panel would never re-render.
|
||||||
|
let expandedBranches = new SvelteSet<string>(BRANCH_IDS); // All expanded by default
|
||||||
|
|
||||||
function openModal(node: TreeNode) {
|
function openModal(node: TreeNode) {
|
||||||
selectedNode = node;
|
selectedNode = node;
|
||||||
@@ -81,7 +84,6 @@
|
|||||||
} else {
|
} else {
|
||||||
expandedBranches.add(branchId);
|
expandedBranches.add(branchId);
|
||||||
}
|
}
|
||||||
expandedBranches = expandedBranches; // Trigger reactivity
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stats calculations (reactive)
|
// Stats calculations (reactive)
|
||||||
@@ -110,16 +112,18 @@
|
|||||||
await app.init({
|
await app.init({
|
||||||
background: '#05050a',
|
background: '#05050a',
|
||||||
resizeTo: container,
|
resizeTo: container,
|
||||||
antialias: true
|
// The scene is soft glow circles — MSAA costs fill rate and buys almost
|
||||||
|
// nothing here. Sharpness comes from rendering at device resolution instead.
|
||||||
|
antialias: false,
|
||||||
|
resolution: Math.min(window.devicePixelRatio || 1, 2),
|
||||||
|
autoDensity: true,
|
||||||
|
powerPreference: 'high-performance'
|
||||||
});
|
});
|
||||||
|
|
||||||
// PixiJS owns this canvas, not Svelte — mounting it directly is intentional.
|
// PixiJS owns this canvas, not Svelte — mounting it directly is intentional.
|
||||||
// eslint-disable-next-line svelte/no-dom-manipulating
|
// eslint-disable-next-line svelte/no-dom-manipulating
|
||||||
container.appendChild(app.canvas);
|
container.appendChild(app.canvas);
|
||||||
|
|
||||||
// Hide loading indicator - PixiJS is ready, welcome title will be LCP
|
|
||||||
isLoading = false;
|
|
||||||
|
|
||||||
// Background layers (gradient → nebula → stars → dust → vignette)
|
// Background layers (gradient → nebula → stars → dust → vignette)
|
||||||
const bg = createBackground(app, isMobile);
|
const bg = createBackground(app, isMobile);
|
||||||
|
|
||||||
@@ -149,6 +153,11 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Textures are loaded and the scene graph is built — only now is the canvas
|
||||||
|
// genuinely ready. (This used to fire right after app.init(), so the spinner
|
||||||
|
// resolved onto an empty screen.)
|
||||||
|
isLoading = false;
|
||||||
|
|
||||||
// Pan / zoom / parallax + keyboard navigation
|
// Pan / zoom / parallax + keyboard navigation
|
||||||
const detachPanZoom = attachPanZoom(controller, {
|
const detachPanZoom = attachPanZoom(controller, {
|
||||||
onTooltipMove: (x, y) => {
|
onTooltipMove: (x, y) => {
|
||||||
|
|||||||
@@ -212,7 +212,9 @@
|
|||||||
4px 0 40px rgba(255, 215, 0, 0.3),
|
4px 0 40px rgba(255, 215, 0, 0.3),
|
||||||
8px 0 80px rgba(255, 215, 0, 0.15);
|
8px 0 80px rgba(255, 215, 0, 0.15);
|
||||||
z-index: 900;
|
z-index: 900;
|
||||||
transform: translateX(-100%);
|
/* Offset past the box-shadow (8px offset + 80px blur) so the gold glow
|
||||||
|
doesn't bleed down the left edge of the viewport while closed. */
|
||||||
|
transform: translateX(calc(-100% - 100px));
|
||||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
|||||||
+2
-2
@@ -366,9 +366,9 @@ export const treeNodes: TreeNode[] = [
|
|||||||
category: 'projects',
|
category: 'projects',
|
||||||
size: 'small',
|
size: 'small',
|
||||||
icon: '/icons/svelte.svg',
|
icon: '/icons/svelte.svg',
|
||||||
techStack: ['SvelteKit', 'TypeScript', 'PixiJS', 'GSAP', 'Tailwind CSS'],
|
techStack: ['SvelteKit', 'TypeScript', 'PixiJS', 'GSAP', 'Vitest'],
|
||||||
projectUrl: 'https://tehriehldeal.com',
|
projectUrl: 'https://tehriehldeal.com',
|
||||||
repoUrl: 'https://github.com/yourusername/skill-tree-portfolio'
|
repoUrl: 'https://git.tehriehldeal.com/TehRiehlDeal/skill-tree-portfolio'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'proj-character-builder',
|
id: 'proj-character-builder',
|
||||||
|
|||||||
+26
-11
@@ -209,18 +209,22 @@ export function createBackground(app: Application, isMobile: boolean): Backgroun
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Animate dust particles drifting
|
// Animate dust particles drifting. Named function + indexed loop: `forEach`
|
||||||
app.ticker.add(() => {
|
// with an inline arrow allocated a fresh closure on every frame.
|
||||||
dustParticles.forEach((p) => {
|
function driftDust() {
|
||||||
p.graphics.x += p.vx;
|
for (let i = 0; i < dustParticles.length; i++) {
|
||||||
p.graphics.y += p.vy;
|
const p = dustParticles[i];
|
||||||
|
const g = p.graphics;
|
||||||
|
g.x += p.vx;
|
||||||
|
g.y += p.vy;
|
||||||
|
|
||||||
if (p.graphics.x < -20) p.graphics.x = screenWidth + 20;
|
if (g.x < -20) g.x = screenWidth + 20;
|
||||||
if (p.graphics.x > screenWidth + 20) p.graphics.x = -20;
|
else if (g.x > screenWidth + 20) g.x = -20;
|
||||||
if (p.graphics.y < -20) p.graphics.y = screenHeight + 20;
|
if (g.y < -20) g.y = screenHeight + 20;
|
||||||
if (p.graphics.y > screenHeight + 20) p.graphics.y = -20;
|
else if (g.y > screenHeight + 20) g.y = -20;
|
||||||
});
|
}
|
||||||
});
|
}
|
||||||
|
app.ticker.add(driftDust);
|
||||||
|
|
||||||
// ========== VIGNETTE OVERLAY ==========
|
// ========== VIGNETTE OVERLAY ==========
|
||||||
const vignette = new Graphics();
|
const vignette = new Graphics();
|
||||||
@@ -234,5 +238,16 @@ export function createBackground(app: Application, isMobile: boolean): Backgroun
|
|||||||
}
|
}
|
||||||
app.stage.addChild(vignette);
|
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 };
|
return { deepBackgroundContainer, nebulaContainer, starContainer };
|
||||||
}
|
}
|
||||||
+16
-8
@@ -1,7 +1,7 @@
|
|||||||
// Tree node rendering: the origin profile node and regular icon nodes.
|
// Tree node rendering: the origin profile node and regular icon nodes.
|
||||||
// Nodes start non-interactive (eventMode 'none') until the entrance animation
|
// Nodes start non-interactive (eventMode 'none') until the entrance animation
|
||||||
// enables them; see animation.ts.
|
// enables them; see animation.ts.
|
||||||
import { Graphics, Container, Assets, Sprite } from 'pixi.js';
|
import { Graphics, Container, Assets, Sprite, type Texture } from 'pixi.js';
|
||||||
import { gsap } from 'gsap';
|
import { gsap } from 'gsap';
|
||||||
import { treeNodes, type TreeNode } from '$lib/data';
|
import { treeNodes, type TreeNode } from '$lib/data';
|
||||||
import { categoryColors } from '$lib/theme';
|
import { categoryColors } from '$lib/theme';
|
||||||
@@ -51,7 +51,16 @@ export async function createNodes(treeContainer: Container, opts: NodeOpts): Pro
|
|||||||
const { isMobile } = opts;
|
const { isMobile } = opts;
|
||||||
const nodeObjects: NodeObject[] = [];
|
const nodeObjects: NodeObject[] = [];
|
||||||
|
|
||||||
const profileTexture = await Assets.load('/headshot.jpg');
|
// Load every texture in ONE parallel batch. Previously each icon was awaited
|
||||||
|
// inside the loop below, which serialised ~56 round-trips before the tree
|
||||||
|
// could render or accept input.
|
||||||
|
const iconUrls = [...new Set(treeNodes.map((n) => n.icon).filter((s): s is string => !!s))];
|
||||||
|
|
||||||
|
const [profileTexture, iconTextures] = await Promise.all([
|
||||||
|
Assets.load<Texture>('/headshot.jpg'),
|
||||||
|
// Array form resolves to a url -> Texture map and fetches concurrently.
|
||||||
|
iconUrls.length ? Assets.load<Texture>(iconUrls) : Promise.resolve({} as Record<string, Texture>)
|
||||||
|
]);
|
||||||
|
|
||||||
for (const node of treeNodes) {
|
for (const node of treeNodes) {
|
||||||
const radius = getNodeRadius(node.size, isMobile);
|
const radius = getNodeRadius(node.size, isMobile);
|
||||||
@@ -107,9 +116,11 @@ export async function createNodes(treeContainer: Container, opts: NodeOpts): Pro
|
|||||||
nodeGraphics.circle(0, 0, radius * 0.75).stroke({ width: 1, color, alpha: 0.2 });
|
nodeGraphics.circle(0, 0, radius * 0.75).stroke({ width: 1, color, alpha: 0.2 });
|
||||||
nodeContainer.addChild(nodeGraphics);
|
nodeContainer.addChild(nodeGraphics);
|
||||||
|
|
||||||
if (node.icon) {
|
const iconTexture = node.icon ? iconTextures[node.icon] : undefined;
|
||||||
try {
|
if (node.icon && !iconTexture) {
|
||||||
const iconTexture = await Assets.load(node.icon);
|
console.warn(`Could not load icon for ${node.id}:`, node.icon);
|
||||||
|
}
|
||||||
|
if (iconTexture) {
|
||||||
const iconSprite = new Sprite(iconTexture);
|
const iconSprite = new Sprite(iconTexture);
|
||||||
|
|
||||||
const iconSize = radius * 1.4;
|
const iconSize = radius * 1.4;
|
||||||
@@ -119,9 +130,6 @@ export async function createNodes(treeContainer: Container, opts: NodeOpts): Pro
|
|||||||
iconSprite.tint = color;
|
iconSprite.tint = color;
|
||||||
|
|
||||||
nodeContainer.addChild(iconSprite);
|
nodeContainer.addChild(iconSprite);
|
||||||
} catch {
|
|
||||||
console.warn(`Could not load icon for ${node.id}:`, node.icon);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nodeContainer.alpha = 0;
|
nodeContainer.alpha = 0;
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.7 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.9 MiB |
+1
-1
@@ -10,7 +10,7 @@ const config = {
|
|||||||
pages: 'build',
|
pages: 'build',
|
||||||
assets: 'build',
|
assets: 'build',
|
||||||
fallback: null,
|
fallback: null,
|
||||||
precompress: false,
|
precompress: true,
|
||||||
strict: true
|
strict: true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user