Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function WipeAnimationOverlay({
>
{/* Static Background Layer (to prevent target theme flash before wipe starts) */}
<div
className="absolute inset-0 bg-no-repeat bg-size-[100%_100%]"
className="absolute inset-0 bg-no-repeat bg-top bg-[length:100%_auto]"
style={{
backgroundImage: `url(${snapshots.a})`,
}}
Expand All @@ -42,7 +42,7 @@ export function WipeAnimationOverlay({

{/* Target Theme Snapshot (Bottom Layer - Revealed) */}
<div
className="absolute inset-0 bg-no-repeat bg-size-[100%_100%]"
className="absolute inset-0 bg-no-repeat bg-top bg-[length:100%_auto]"
style={{
backgroundImage: `url(${snapshots.b})`,
}}
Expand All @@ -51,7 +51,7 @@ export function WipeAnimationOverlay({
{/* Original Theme Snapshot (Top Layer - Wiped Away) */}
<motion.div
key="theme-switcher-overlay"
className="absolute inset-0 bg-no-repeat bg-size-[100%_100%]"
className="absolute inset-0 bg-no-repeat bg-top bg-[length:100%_auto]"
style={{
backgroundImage: `url(${snapshots.a})`,
clipPath,
Expand Down
68 changes: 44 additions & 24 deletions src/hooks/useThemeWipe.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"use client";

import { useState, useCallback, Dispatch, SetStateAction } from "react";
import { useState, useCallback, Dispatch, SetStateAction, useMemo } from "react";
import { useTheme } from "next-themes";
import { usePathname } from "next/navigation";
import { domToPng } from "modern-screenshot";
import { getFullPageHTML } from "@/utils/dom-serializer";
import { useWipeAnimation } from "@/hooks/useWipeAnimation";
Expand All @@ -26,16 +27,31 @@ export function useThemeWipe({
setWipeDirection,
}: UseThemeWipeProps) {
const { setTheme, resolvedTheme } = useTheme();
const pathname = usePathname();
const [snapshots, setSnapshots] = useState<Snapshots | null>(null);
const [isCapturing, setIsCapturing] = useState(false);
const [animationTargetTheme, setAnimationTargetTheme] = useState<Theme | null>(null);
const [originalTheme, setOriginalTheme] = useState<Theme | null>(null);

const isPuppeteerPage = useMemo(() => {
// We want to use Puppeteer on:
// 1. Homepage: / or /[variant] (e.g. /tim, /tiger)
// 2. Projects: /projects or /[variant]/projects

const parts = pathname.split('/').filter(Boolean);

// Homepage: / or /tim or /tiger
if (parts.length === 0 || parts.length === 1) return true;

// Projects pages: /projects or /tim/projects or /tiger/projects
if (parts.length === 1 && parts[0] === 'projects') return true;
if (parts.length === 2 && parts[1] === 'projects') return true;

return false;
}, [pathname]);

const setScrollLock = (isLocked: boolean) => {
const hasScrollbar = window.innerWidth > document.documentElement.clientWidth;
document.documentElement.style.overflow = isLocked ? 'hidden' : '';
// Only reserve gutter space if a scrollbar was actually present to prevent layout shift on mobile
document.documentElement.style.scrollbarGutter = (isLocked && hasScrollbar) ? 'stable' : '';
};

const handleAnimationComplete = useCallback(() => {
Expand Down Expand Up @@ -72,7 +88,7 @@ export function useThemeWipe({
});

const toggleTheme = useCallback(async () => {
const currentTheme = resolvedTheme as Theme;
const currentTheme = (resolvedTheme as Theme) || "light";
const newTheme: Theme = currentTheme === "dark" ? "light" : "dark";

if (snapshots || isCapturing || wipeDirection) {
Expand Down Expand Up @@ -106,7 +122,7 @@ export function useThemeWipe({
return true;
},
style: {
width: `${document.documentElement.clientWidth}px`,
width: `${window.innerWidth}px`,
height: `${document.documentElement.scrollHeight}px`,
transform: `translateY(-${scrollY}px)`,
transformOrigin: 'top left',
Expand All @@ -115,9 +131,9 @@ export function useThemeWipe({
return await domToPng(document.documentElement, options);
};

const fetchSnapshotsBatch = async (newTheme: Theme) => {
const fetchSnapshotsBatch = async (currentTheme: Theme, newTheme: Theme) => {
// 1. Snapshot A (current)
const htmlA = await getFullPageHTML();
const htmlA = await getFullPageHTML(currentTheme);

// 2. Switch theme (to handle layouts that require re-render)
setTheme(newTheme);
Expand All @@ -126,7 +142,7 @@ export function useThemeWipe({
await new Promise(r => setTimeout(r, 250));

// 3. Snapshot B (newly rendered theme)
const htmlB = await getFullPageHTML();
const htmlB = await getFullPageHTML(newTheme);

// 4. Restore original theme state before sending to API
// This ensures the live page matches Snapshot A when the wipe animation starts.
Expand Down Expand Up @@ -209,21 +225,25 @@ export function useThemeWipe({
const mask = await captureMask();
setSnapshots({ a: mask, b: mask, method: "Capturing..." });

// PHASE 1: Try Puppeteer (20s timeout as per instructions)
console.log("Attempting Puppeteer snapshot...");
const [snapshotA, snapshotB] = await withTimeout(
fetchSnapshotsBatch(newTheme),
20000,
"Puppeteer timeout"
) as [string, string];

setSnapshots({ a: snapshotA, b: snapshotB, method: "Puppeteer" });
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
setTheme(newTheme);
setWipeDirection(direction);

} catch (e: any) {
console.warn("Puppeteer failed or timed out, falling back to modern-screenshot:", e.message);
if (isPuppeteerPage) {
// PHASE 1: Try Puppeteer (20s timeout as per instructions)
console.log("Attempting Puppeteer snapshot...");
try {
const [snapshotA, snapshotB] = await withTimeout(
fetchSnapshotsBatch(currentTheme, newTheme),
20000,
"Puppeteer timeout"
) as [string, string];

setSnapshots({ a: snapshotA, b: snapshotB, method: "Puppeteer" });
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
setTheme(newTheme);
setWipeDirection(direction);
return; // Exit on success
} catch (e: any) {
console.warn("Puppeteer failed or timed out, falling back to modern-screenshot:", e.message);
}
}

try {
// PHASE 2: Try modern-screenshot (15s timeout as per instructions)
Expand Down
15 changes: 6 additions & 9 deletions src/utils/dom-serializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,15 +181,12 @@ export async function getFullPageHTML(themeOverride?: "light" | "dark"): Promise

if (themeOverride) {
// next-themes typically uses class="dark" or class="light" on html
if (themeOverride === "dark") {
doc.classList.add("dark");
doc.classList.remove("light");
} else {
doc.classList.add("light");
doc.classList.remove("dark");
}
doc.classList.remove("light", "dark");
doc.classList.add(themeOverride);
// Also handle data-theme if present
doc.setAttribute("data-theme", themeOverride);
// Ensure the background color matches the theme
doc.style.colorScheme = themeOverride;
}

const body = doc.querySelector('body');
Expand Down Expand Up @@ -298,9 +295,9 @@ export async function getFullPageHTML(themeOverride?: "light" | "dark"): Promise
const scripts = doc.querySelectorAll('script, noscript, template, iframe');
scripts.forEach(s => s.remove());

// Hide the switcher and overlay
// Hide the switcher and overlay - REMOVE them instead of just display: none
const itemsToHide = doc.querySelectorAll('[data-html2canvas-ignore]');
itemsToHide.forEach(el => (el as HTMLElement).style.display = 'none');
itemsToHide.forEach(el => el.remove());

const htmlAttrs = Array.from(doc.attributes).map(a => `${a.name}="${a.value}"`).join(' ');
return `<!DOCTYPE html><html ${htmlAttrs}>${doc.innerHTML}</html>`;
Expand Down