From abf4348c5d6db9814e8bcba72419b08a975acbe5 Mon Sep 17 00:00:00 2001
From: TimChinye <150863066+TimChinye@users.noreply.github.com>
Date: Thu, 5 Mar 2026 09:47:11 +0000
Subject: [PATCH 1/2] Refine theme wipe transition logic and fix visual issues
- Strictly limit Puppeteer snapshots to home (`/`, `/tim`, `/tiger`) and projects listing pages.
- Implement multi-tier fallback (Puppeteer -> modern-screenshot -> Instant) for all other routes.
- Remove scrollbar-gutter logic to eliminate the "yellow fake scrollbar" artifact.
- Fix snapshot coverage by using `bg-[length:100%_100%]` and capturing `window.innerWidth`.
- Improve mask capture timing to prevent theme flashing during heavy background tasks.
- Align the wipe divider line accurately to the clip-path edge using percentage-based positioning.
- Improve DOM serialization to handle theme overrides and remove ignored elements.
---
.../ThemeSwitcher/ui/WipeAnimationOverlay.tsx | 8 +-
src/hooks/useThemeWipe.ts | 105 +++++++++++-------
src/hooks/useWipeAnimation.ts | 2 +-
src/utils/dom-serializer.ts | 15 +--
4 files changed, 78 insertions(+), 52 deletions(-)
diff --git a/src/components/features/ThemeSwitcher/ui/WipeAnimationOverlay.tsx b/src/components/features/ThemeSwitcher/ui/WipeAnimationOverlay.tsx
index 12de498..94955f7 100644
--- a/src/components/features/ThemeSwitcher/ui/WipeAnimationOverlay.tsx
+++ b/src/components/features/ThemeSwitcher/ui/WipeAnimationOverlay.tsx
@@ -27,7 +27,7 @@ export function WipeAnimationOverlay({
>
{/* Static Background Layer (to prevent target theme flash before wipe starts) */}
diff --git a/src/hooks/useThemeWipe.ts b/src/hooks/useThemeWipe.ts
index 4f9a683..7cc4a3b 100644
--- a/src/hooks/useThemeWipe.ts
+++ b/src/hooks/useThemeWipe.ts
@@ -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";
@@ -26,16 +27,39 @@ export function useThemeWipe({
setWipeDirection,
}: UseThemeWipeProps) {
const { setTheme, resolvedTheme } = useTheme();
+ const pathname = usePathname();
const [snapshots, setSnapshots] = useState(null);
const [isCapturing, setIsCapturing] = useState(false);
const [animationTargetTheme, setAnimationTargetTheme] = useState(null);
const [originalTheme, setOriginalTheme] = useState(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(() => {
@@ -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) {
@@ -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;
@@ -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',
@@ -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);
@@ -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.
@@ -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)
diff --git a/src/hooks/useWipeAnimation.ts b/src/hooks/useWipeAnimation.ts
index 4719d31..81c83a1 100644
--- a/src/hooks/useWipeAnimation.ts
+++ b/src/hooks/useWipeAnimation.ts
@@ -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 };
diff --git a/src/utils/dom-serializer.ts b/src/utils/dom-serializer.ts
index 5b658a0..873e609 100644
--- a/src/utils/dom-serializer.ts
+++ b/src/utils/dom-serializer.ts
@@ -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');
@@ -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 `${doc.innerHTML}`;
From 5fbf8ce239568d90c1fe4f8fb189f89d9611e768 Mon Sep 17 00:00:00 2001
From: Tim
Date: Sun, 8 Mar 2026 19:33:53 +0000
Subject: [PATCH 2/2] Minor change
---
.../[variant]/projects/_components/ProjectsHero/Client.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/app/(portfolio)/[variant]/projects/_components/ProjectsHero/Client.tsx b/src/app/(portfolio)/[variant]/projects/_components/ProjectsHero/Client.tsx
index 794f277..7b0b33d 100644
--- a/src/app/(portfolio)/[variant]/projects/_components/ProjectsHero/Client.tsx
+++ b/src/app/(portfolio)/[variant]/projects/_components/ProjectsHero/Client.tsx
@@ -67,7 +67,7 @@ export function Client({ projects, ...props }: ProjectsHeroClientProps) {
@@ -95,7 +95,7 @@ export function Client({ projects, ...props }: ProjectsHeroClientProps) {
{/* Highlighted Projects */}
-
+
{projects.map((project) => (
))}