Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/site/src/layouts/base-layout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const prefetchTargets = ['/', '/journey/', '/projects/', '/resume/', '/contact/'
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="generator" content={Astro.generator} />
<script is:inline>
<script is:inline data-astro-rerun>
(() => {
const html = document.documentElement;
const isDark = (() => {
Expand Down
5 changes: 5 additions & 0 deletions apps/web/site/src/scripts/background.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@ describe('initBackground', () => {
});

it('handles shader loading failures without throwing', async () => {
const { __resetBackgroundForTesting } = await import('./background');
__resetBackgroundForTesting();

document.body.innerHTML = `
<div id="light-bg"></div>
<div id="dark-bg"></div>
Expand All @@ -115,6 +118,8 @@ describe('initBackground', () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);

globalThis.fetch = vi.fn().mockRejectedValue(new Error('network')) as typeof fetch;
window.requestAnimationFrame = vi.fn().mockImplementation(() => 1);

await initBackground();

expect(consoleError).toHaveBeenCalled();
Expand Down
184 changes: 107 additions & 77 deletions apps/web/site/src/scripts/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type BackgroundState = {
renderer: THREE.WebGLRenderer;
material: THREE.ShaderMaterial;
resolution: THREE.Vector2;
darkBackground: HTMLElement;
};

type Cleanup = () => void;
Expand All @@ -20,6 +21,8 @@ let state: BackgroundState | null = null;
let frameId: number | null = null;
let isRunning = false;
const cleanups: Cleanup[] = [];
let vertexShaderText: string | null = null;
let fragmentShaderText: string | null = null;

const stopLoop = () => {
isRunning = false;
Expand Down Expand Up @@ -49,12 +52,12 @@ const startLoop = () => {
frameId = window.requestAnimationFrame(tick);
};

const syncLoop = (darkBackground: HTMLElement | null) => {
const syncLoop = () => {
if (!state) {
return;
}

const shouldRender = !document.hidden && darkBackground !== null && !darkBackground.classList.contains('hidden');
const shouldRender = !document.hidden && !state.darkBackground.classList.contains('hidden');

if (shouldRender) {
startLoop();
Expand All @@ -79,6 +82,27 @@ const teardown = () => {
}
};

const loadShaders = async (): Promise<[string, string] | null> => {
if (vertexShaderText !== null && fragmentShaderText !== null) {
return [vertexShaderText, fragmentShaderText];
}

try {
const [vertex, fragment] = await Promise.all([
fetch(vertexShaderUrl).then((response) => response.text()),
fetch(fragmentShaderUrl).then((response) => response.text()),
]);

vertexShaderText = vertex;
fragmentShaderText = fragment;

return [vertex, fragment];
} catch (error) {
console.error('Failed to load background shaders:', error);
return null;
}
};

export const initBackground = async () => {
if (state) {
teardown();
Expand All @@ -102,17 +126,12 @@ export const initBackground = async () => {
lightBackground.classList.remove('hidden');
}

let currentTheme: 'light' | 'dark' = isDarkOnLoad ? 'dark' : 'light';

const themeObserver = new MutationObserver(() => {
const isDark = html.classList.contains('dark');
const nextTheme: 'light' | 'dark' = isDark ? 'dark' : 'light';

if (nextTheme === currentTheme) {
if (!state) {
return;
}

currentTheme = nextTheme;
const isDark = html.classList.contains('dark');

if (isDark) {
lightBackground.classList.remove('bg-upward-enter');
Expand All @@ -123,7 +142,7 @@ export const initBackground = async () => {

window.setTimeout(() => {
lightBackground.classList.add('hidden');
syncLoop(darkBackground);
syncLoop();
}, 700);
return;
}
Expand All @@ -136,88 +155,96 @@ export const initBackground = async () => {

window.setTimeout(() => {
darkBackground.classList.add('hidden');
syncLoop(darkBackground);
syncLoop();
}, 700);
});

themeObserver.observe(html, { attributes: true, attributeFilter: ['class'] });
cleanups.push(() => themeObserver.disconnect());

try {
const [vertexShader, fragmentShader] = await Promise.all([
fetch(vertexShaderUrl).then((response) => response.text()),
fetch(fragmentShaderUrl).then((response) => response.text()),
]);
const shaders = await loadShaders();

if (!shaders) {
return;
}

const [vertexShader, fragmentShader] = shaders;

const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const resolution = new THREE.Vector2(window.innerWidth, window.innerHeight);

const material = new THREE.ShaderMaterial({
uniforms: {
iTime: { value: 0 },
iResolution: { value: resolution },
},
vertexShader,
fragmentShader,
});

const geometry = new THREE.PlaneGeometry(2, 2);
const mesh = new THREE.Mesh(geometry, material);

scene.add(mesh);

const renderer = new THREE.WebGLRenderer({
antialias: false,
alpha: true,
powerPreference: 'low-power',
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, maxPixelRatio));
renderer.setSize(resolution.x, resolution.y);
darkBackground.appendChild(renderer.domElement);

state = { scene, camera, renderer, material, resolution };

const onResize = () => {
if (!state) {
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const resolution = new THREE.Vector2(window.innerWidth, window.innerHeight);

const material = new THREE.ShaderMaterial({
uniforms: {
iTime: { value: 0 },
iResolution: { value: resolution },
},
vertexShader,
fragmentShader,
});

const geometry = new THREE.PlaneGeometry(2, 2);
const mesh = new THREE.Mesh(geometry, material);

scene.add(mesh);

const renderer = new THREE.WebGLRenderer({
antialias: false,
alpha: true,
powerPreference: 'low-power',
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, maxPixelRatio));
renderer.setSize(resolution.x, resolution.y);
darkBackground.appendChild(renderer.domElement);

state = { scene, camera, renderer, material, resolution, darkBackground };

const onResize = () => {
if (!state) {
return;
}

const width = window.innerWidth;
const height = window.innerHeight;

renderer.setSize(width, height);
resolution.set(width, height);
};

window.addEventListener('resize', onResize);
cleanups.push(() => window.removeEventListener('resize', onResize));

const onVisibilityChange = () => syncLoop();

document.addEventListener('visibilitychange', onVisibilityChange);
cleanups.push(() => document.removeEventListener('visibilitychange', onVisibilityChange));

const io = new IntersectionObserver(
(entries) => {
const entry = entries[0];

if (entry?.isIntersecting === false) {
stopLoop();
return;
}

const width = window.innerWidth;
const height = window.innerHeight;

renderer.setSize(width, height);
resolution.set(width, height);
};

window.addEventListener('resize', onResize);
cleanups.push(() => window.removeEventListener('resize', onResize));

document.addEventListener('visibilitychange', () => syncLoop(darkBackground));
cleanups.push(() => document.removeEventListener('visibilitychange', () => syncLoop(darkBackground)));

{
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
syncLoop(entry?.isIntersecting ? darkBackground : null);
},
{ threshold: 0 },
);
observer.observe(darkBackground);
cleanups.push(() => observer.disconnect());
}
syncLoop();
},
{ threshold: 0 },
);
io.observe(darkBackground);
cleanups.push(() => io.disconnect());

syncLoop(darkBackground);
} catch (error) {
console.error('Failed to initialize 3D background:', error);
teardown();
}
syncLoop();
};

export const destroyBackground = () => teardown();

document.addEventListener('astro:before-swap', () => teardown());
export const __resetBackgroundForTesting = () => {
teardown();
vertexShaderText = null;
fragmentShaderText = null;
};

const start = () => {
void initBackground();
Expand All @@ -228,3 +255,6 @@ if (document.readyState === 'loading') {
} else {
start();
}

document.addEventListener('astro:before-swap', teardown);
document.addEventListener('astro:page-load', start);
26 changes: 18 additions & 8 deletions apps/web/site/src/scripts/navbar.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
export const initNavbar = () => {
const button = document.getElementById('mobile-menu-btn');
const menu = document.getElementById('mobile-menu');
const attach = () => {
const button = document.getElementById('mobile-menu-btn');
const menu = document.getElementById('mobile-menu');

if (!button || !menu) {
return;
}
if (!button || !menu) {
return;
}

button.addEventListener('click', () => {
menu.classList.toggle('hidden');
});
if (button.dataset['navbarBound'] === 'true') {
return;
}

button.dataset['navbarBound'] = 'true';
button.addEventListener('click', () => {
menu.classList.toggle('hidden');
});
};

attach();
document.addEventListener('astro:page-load', attach);
};

if (document.readyState === 'loading') {
Expand Down
Loading
Loading