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 @@ -67,7 +67,7 @@ export function Client({ projects, ...props }: ProjectsHeroClientProps) {
<main
style={{ borderRadius: 0 }}
className="
relative flex flex-col justify-between overflow-hidden bg-[#EFEFD0]
relative flex flex-col justify-between overflow-hidden bg-[#EFEFD0] dark:bg-[#D9C97C]
max-md:min-h-[calc(100vh-(var(--spacing)*32))] max-md:w-full
md:h-screen md:flex-1 md:flex-row md:rounded-tl-[4rem]
">
Expand Down Expand Up @@ -95,7 +95,7 @@ export function Client({ projects, ...props }: ProjectsHeroClientProps) {
</div>

{/* Highlighted Projects */}
<div className="flex shrink-0 bg-[#EFEFD0] max-md:w-full max-md:flex-col md:h-full md:flex-row">
<div className="flex shrink-0 bg-[#EFEFD0] dark:bg-[#CBBA6A] max-md:w-full max-md:flex-col md:h-full md:flex-row">
{projects.map((project) => (
<ProjectStrip key={project._id} project={project} />
))}
Expand Down
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%_100%]"
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%_100%]"
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%_100%]"
style={{
backgroundImage: `url(${snapshots.a})`,
clipPath,
Expand All @@ -64,7 +64,7 @@ export function WipeAnimationOverlay({
className="absolute left-0 w-full h-1 bg-[#D9D24D]"
style={{
top: dividerTop,
translate: wipeDirection === "top-down" ? "0 -100%" : "0 0",
translate: "0 -50%",
}}
/>
</div>
Expand Down
105 changes: 67 additions & 38 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,39 @@ 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 ONLY on:
// 1. Homepage: / or /tim or /tiger
// 2. Projects listing: /projects or /tim/projects or /tiger/projects

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

// Homepage: /
if (parts.length === 0) return true;

// /tim or /tiger or /projects
if (parts.length === 1) {
const p0 = parts[0];
return p0 === 'tim' || p0 === 'tiger' || p0 === 'projects';
}

// /tim/projects or /tiger/projects
if (parts.length === 2) {
const [p0, p1] = parts;
return (p0 === 'tim' || p0 === 'tiger') && p1 === 'projects';
}

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 +96,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 All @@ -81,16 +105,6 @@ export function useThemeWipe({
return;
}

setIsCapturing(true);
setScrollLock(true);
document.documentElement.classList.add('disable-transitions');

setOriginalTheme(currentTheme);
setAnimationTargetTheme(newTheme);

const direction: WipeDirection =
currentTheme === "dark" ? "bottom-up" : "top-down";

const captureMask = async () => {
const vh = window.innerHeight;
const scrollY = window.scrollY;
Expand All @@ -106,7 +120,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 +129,24 @@ export function useThemeWipe({
return await domToPng(document.documentElement, options);
};

const fetchSnapshotsBatch = async (newTheme: Theme) => {
// PHASE 0: Capture Mask to prevent theme flash
// We do this immediately before any React state changes to ensure a clean capture
const mask = await captureMask();
setSnapshots({ a: mask, b: mask, method: "Capturing..." });

setIsCapturing(true);
setScrollLock(true);
document.documentElement.classList.add('disable-transitions');

setOriginalTheme(currentTheme);
setAnimationTargetTheme(newTheme);

const direction: WipeDirection =
currentTheme === "dark" ? "bottom-up" : "top-down";

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 +155,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 @@ -205,25 +234,25 @@ export function useThemeWipe({
};

try {
// PHASE 0: Capture Mask to prevent theme flash
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
2 changes: 1 addition & 1 deletion src/hooks/useWipeAnimation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export function useWipeAnimation({
const dividerTop = useTransform(
wipeProgress,
[0, 100],
wipeDirection === "top-down" ? ["0vh", "100vh"] : ["100vh", "0vh"]
wipeDirection === "top-down" ? ["0%", "100%"] : ["100%", "0%"]
);

return { clipPath, dividerTop, wipeProgress };
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