From 5e316e9e9b4dc56f0a9974de08a5572fa300bd2a Mon Sep 17 00:00:00 2001 From: LucaDeLeo Date: Mon, 14 Sep 2026 19:06:06 +0100 Subject: [PATCH 1/2] Keep the timeline background on screen while scrolling and resizing Three defects in the fixed background canvas, all reported as "the About page looks broken on mobile". Parallax ran off the page. The canvas is translated up by scrollY * 0.02 while its container reserves a flat 10vh to cover the shift - a budget of five viewport heights. The About page is ~26 viewports on a phone, so past ~4,200px of scroll the canvas slid out from under the viewport and left a hard-edged #f5f5f5 band, reaching ~430px of an 844px screen at full scroll. Clamp the offset to the reserved buffer, and drive both the buffer and the factor from shared constants so they cannot drift apart again. The renderer died on rotation. A threadCount change resets threads to [], which unmounts the canvas; the init effect did not depend on threads, so the replacement element was never handed to a worker and nothing drew on it again. A phone rotation crosses three threadCount thresholds, so rotating once blanked the background until reload. Depend on threads.length, and key the canvas so every re-init gets a virgin element - transferControlToOffscreen only works once per canvas. The canvas was never resized. Its backing store is sized once, at transfer time, and neither renderer reads the box again, so any resize that left the performance profile alone stretched a stale bitmap across the new box. Watch the box with a ResizeObserver and rebuild when it diverges from the size the renderer was built at, settling for 250ms so a window drag does not respawn a worker every frame. Co-Authored-By: Claude Opus 5 (1M context) --- app/components/timeline-threads.tsx | 95 ++++++++++++++++++++++++++--- 1 file changed, 86 insertions(+), 9 deletions(-) diff --git a/app/components/timeline-threads.tsx b/app/components/timeline-threads.tsx index 6957a8e..f3818b8 100644 --- a/app/components/timeline-threads.tsx +++ b/app/components/timeline-threads.tsx @@ -55,6 +55,27 @@ const DEFAULT_PERFORMANCE_PROFILE: PerformanceProfile = { const LOW_POWER_FRAME_INTERVAL = 1000 / 45; +/** + * Parallax: the canvas is translated up by scrollY * PARALLAX_FACTOR, and the + * container reserves PARALLAX_BUFFER_VH of extra height to cover that shift. + * The offset is clamped to the buffer, because any page taller than + * PARALLAX_BUFFER_VH / PARALLAX_FACTOR viewports (5 screens at these values) + * would otherwise drag the canvas off the bottom of the viewport and expose a + * flat band. The About page runs to ~30 viewports on a phone. + */ +const PARALLAX_FACTOR = 0.02; +const PARALLAX_BUFFER_VH = 0.1; + +/** Device-pixel cap for the render target; a big fill-rate win on phones. */ +const MAX_RENDER_DPR = 2; + +/** + * How long the canvas box has to hold still before the renderer is rebuilt at + * the new size. A window drag fires ResizeObserver every frame, and each + * rebuild spawns a Worker and a GPU context. + */ +const RESIZE_SETTLE_MS = 250; + type NavigatorConnection = { saveData?: boolean; effectiveType?: string; @@ -250,6 +271,9 @@ function TimelineThreadsComponent({ const [performanceProfile, setPerformanceProfile] = useState(DEFAULT_PERFORMANCE_PROFILE); const parallaxRef = useRef(0); + // Bumped when the canvas box no longer matches the size the renderer was + // built at; feeds the renderer key and the init effect. + const [rendererEpoch, setRendererEpoch] = useState(0); const [webglSupported, setWebglSupported] = useState(true); // Apply override parameters if provided @@ -445,16 +469,62 @@ function TimelineThreadsComponent({ } }, [isVisible, shouldAnimate]); + /** + * The OffscreenCanvas is sized once, at transfer time, and neither renderer + * reads the box again. Any resize that leaves the performance profile alone + * (a plain window drag, or one that stays inside a thread-count bucket) would + * otherwise stretch a stale bitmap across the new box. Watch the real box and + * rebuild the renderer when it diverges from what was built. + */ + useEffect(() => { + const container = containerRef.current; + if (!container || typeof ResizeObserver === "undefined") return; + + let settleTimer: ReturnType | undefined; + + const checkSize = () => { + const canvas = canvasRef.current; + const built = canvasDeviceSizeRef.current; + // Null until the renderer has initialized: nothing to compare against. + if (!canvas || !built) return; + + const rect = canvas.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return; + + const dpr = Math.min(window.devicePixelRatio || 1, MAX_RENDER_DPR); + const w = Math.max(1, Math.floor(rect.width * dpr)); + const h = Math.max(1, Math.floor(rect.height * dpr)); + + // Sub-pixel churn is not worth a rebuild. + if (Math.abs(w - built.w) < 2 && Math.abs(h - built.h) < 2) return; + + setRendererEpoch((n) => n + 1); + }; + + const observer = new ResizeObserver(() => { + clearTimeout(settleTimer); + settleTimer = setTimeout(checkSize, RESIZE_SETTLE_MS); + }); + observer.observe(container); + + return () => { + clearTimeout(settleTimer); + observer.disconnect(); + }; + }, []); + // Parallax scroll effect (imperative to avoid React re-renders) useEffect(() => { if (typeof window === "undefined" || prefersReducedMotion) return; - const PARALLAX_FACTOR = 0.02; // Subtle parallax effect (reduced to prevent bottom gap) let rafId = 0; let currentScroll = window.scrollY; const updateParallax = () => { - parallaxRef.current = -currentScroll * PARALLAX_FACTOR; + // window.innerHeight is never larger than the vh the buffer is sized in + // (on iOS vh tracks the large viewport), so this stays within the buffer. + const maxOffset = window.innerHeight * PARALLAX_BUFFER_VH; + parallaxRef.current = -Math.min(currentScroll * PARALLAX_FACTOR, maxOffset); if (canvasRef.current) { canvasRef.current.style.transform = `translateY(${parallaxRef.current}px)`; } @@ -499,9 +569,8 @@ function TimelineThreadsComponent({ if (!canvas) return; try { - // Cap DPR to 2 on mobile (huge fill-rate win) const baseDpr = window.devicePixelRatio || 1; - const dpr = Math.min(baseDpr, 2); + const dpr = Math.min(baseDpr, MAX_RENDER_DPR); // Disable blur on narrow viewports or high-DPR devices (fill-rate optimization) const isNarrow = (window.innerWidth || 0) <= 480; @@ -647,7 +716,11 @@ function TimelineThreadsComponent({ logTimelineDebug("Canvas renderer cleaned up"); }; - }, [shouldAnimate, blurStdDeviation, frameInterval]); + // threads.length is a dependency because a threadCount change (any viewport + // resize crossing a width threshold, including a phone rotation) resets + // threads to [], which unmounts the canvas. The replacement element needs a + // fresh transferControlToOffscreen() or nothing ever draws on it again. + }, [shouldAnimate, blurStdDeviation, frameInterval, threads.length, rendererEpoch]); // Pointer interaction: forward mouse position to the worker in the same // normalized viewbox space as the thread points. rAF-throttled; the worker @@ -737,10 +810,13 @@ function TimelineThreadsComponent({ ); } - // Extra height buffer to prevent blank gap at bottom during parallax scroll. - // Parallax moves canvas UP by (scrollY * 0.02), so we need extra height at bottom. - // 10vh covers typical scroll depths (10vh / 0.02 = 500vh of page scroll). - const parallaxBuffer = "10vh"; + // Extra height buffer covering the parallax shift; updateParallax clamps the + // offset to it so the canvas can never expose a gap at the bottom. + const parallaxBuffer = `${PARALLAX_BUFFER_VH * 100}vh`; + + // Keyed on the renderer's inputs so every re-initialization mounts a fresh + // canvas: transferControlToOffscreen() can only be called once per element. + const rendererKey = `${threads.length}:${blurStdDeviation}:${frameInterval}:${rendererEpoch}`; return (
Date: Mon, 14 Sep 2026 19:06:06 +0100 Subject: [PATCH 2/2] Add Bun test type definitions course-opportunities.test.ts imports from "bun:test", which had no types installed, leaving one error in bunx tsc --noEmit. TypeScript 6 in this project does not auto-include node_modules/@types, so naming the package explicitly is required on top of installing it. Co-Authored-By: Claude Opus 5 (1M context) --- bun.lock | 5 +++++ package.json | 1 + tsconfig.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/bun.lock b/bun.lock index 64e8c6a..b0814b4 100644 --- a/bun.lock +++ b/bun.lock @@ -22,6 +22,7 @@ "@eslint/js": "^10.0.1", "@next/bundle-analyzer": "^16.2.3", "@tailwindcss/postcss": "^4.2.2", + "@types/bun": "^1.4.2", "@types/node": "^25.6.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -251,6 +252,8 @@ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], + "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], @@ -389,6 +392,8 @@ "browserslist": ["browserslist@4.28.0", "", { "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", "electron-to-chromium": "^1.5.249", "node-releases": "^2.0.27", "update-browserslist-db": "^1.1.4" }, "bin": { "browserslist": "cli.js" } }, "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ=="], + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], diff --git a/package.json b/package.json index fe6be65..3cba7c3 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@eslint/js": "^10.0.1", "@next/bundle-analyzer": "^16.2.3", "@tailwindcss/postcss": "^4.2.2", + "@types/bun": "^1.4.2", "@types/node": "^25.6.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/tsconfig.json b/tsconfig.json index e7ff3a2..752c24d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,9 @@ "isolatedModules": true, "jsx": "react-jsx", "incremental": true, + "types": [ + "bun" + ], "plugins": [ { "name": "next"