From bc60483f9f62596177babfa1023e8daf563c91d5 Mon Sep 17 00:00:00 2001 From: donbr <7340008+donbr@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:25:08 -0700 Subject: [PATCH 01/10] fix(nav): close the mobile menu on any navigation and refocus on Escape The menu only closed when a link was clicked, so back/forward left it open. Open state is now tied to the location it was opened at (location.key), so any navigation closes it without an extra effect. Escape also returns focus to the menu button instead of leaving it on an unmounted link. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ppa5DyJCZ1CowKtz58QARN --- src/components/layout/Layout.tsx | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index df446e4..b63957c 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { Link, useLocation } from 'react-router-dom'; import { Menu, X } from 'lucide-react'; import { cn } from '@/lib/utils.ts'; @@ -41,13 +41,22 @@ const navStyles = { const Layout: React.FC = ({ children, title }) => { const location = useLocation(); - const [menuOpen, setMenuOpen] = useState(false); + const menuButtonRef = useRef(null); - // Close the mobile menu on Escape + // The menu is open only for the location it was opened at, so any navigation + // (link, back/forward, hash change) closes it without an extra effect + const [menuOpenAt, setMenuOpenAt] = useState(null); + const menuOpen = menuOpenAt === location.key; + const closeMenu = () => setMenuOpenAt(null); + + // Close the mobile menu on Escape and return focus to the toggle button useEffect(() => { if (!menuOpen) return; const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') setMenuOpen(false); + if (e.key === 'Escape') { + setMenuOpenAt(null); + menuButtonRef.current?.focus(); + } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); @@ -61,7 +70,6 @@ const Layout: React.FC = ({ children, title }) => { const renderNavLink = (item: NavItem, variant: keyof typeof navStyles) => { const styles = navStyles[variant]; const classes = cn(styles.base, isItemActive(item) ? styles.active : styles.inactive); - const closeMenu = () => setMenuOpen(false); if (item.kind === 'route') { return ( @@ -110,12 +118,13 @@ const Layout: React.FC = ({ children, title }) => { {/* Mobile menu button */} From b388c4b3c918dfaf2bc6c8bbf4f9c9e3afe2e178 Mon Sep 17 00:00:00 2001 From: donbr <7340008+donbr@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:25:08 -0700 Subject: [PATCH 02/10] fix(nav): update the homepage hash through React Router The smooth-scroll handler wrote the hash with window.history.pushState, which React Router never sees: location.hash went stale (nav underline lagged) and back/forward across those entries didn't register as navigation. It now calls navigate({ hash }), keeping the router, the active nav item and history in sync. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ppa5DyJCZ1CowKtz58QARN --- src/pages/HomePage.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index a07b56b..ee74cff 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -1,8 +1,10 @@ import React, { useEffect } from 'react'; -import { Link } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; import Layout from '@/components/layout/Layout'; const HomePage: React.FC = () => { + const navigate = useNavigate(); + // Handle smooth scrolling for anchor links useEffect(() => { const handleAnchorClick = (e: MouseEvent) => { @@ -12,15 +14,16 @@ const HomePage: React.FC = () => { const element = document.querySelector(target.hash); if (element) { element.scrollIntoView({ behavior: 'smooth' }); - // Update URL without reload - window.history.pushState(null, '', target.hash); + // Update the URL through the router (not history.pushState) so location.hash, + // the nav's active state and back/forward stay in sync + navigate({ hash: target.hash }); } } }; document.addEventListener('click', handleAnchorClick); return () => document.removeEventListener('click', handleAnchorClick); - }, []); + }, [navigate]); return ( From 10bc2435b67b3b731ea8ffdf12a6022c7dda9ce5 Mon Sep 17 00:00:00 2001 From: donbr <7340008+donbr@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:25:08 -0700 Subject: [PATCH 03/10] fix(a11y): drop the duplicate Deep Dive tab stop on project cards Cards with a detail page had the stretched title link plus a separate Deep Dive link to the same URL, so keyboard and screen-reader users hit two stops for one destination. Deep Dive stays as a visual cue (aria-hidden text under the card overlay, so clicking it still opens the detail page). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ppa5DyJCZ1CowKtz58QARN --- src/pages/ProjectsPage.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/pages/ProjectsPage.tsx b/src/pages/ProjectsPage.tsx index a0d91a9..adb1220 100644 --- a/src/pages/ProjectsPage.tsx +++ b/src/pages/ProjectsPage.tsx @@ -73,7 +73,7 @@ const ProjectCard: React.FC = ({ }; return ( -
+

{detailUrl ? ( // Stretched link: the ::after overlay makes the whole card clickable without nesting links @@ -100,12 +100,14 @@ const ProjectCard: React.FC = ({ {/* [&>a]: lifts every action link above the title's stretched-link overlay */}
{detailUrl && ( - Deep Dive → - + )} {demoUrl && ( Date: Fri, 2 Oct 2026 14:25:08 -0700 Subject: [PATCH 04/10] chore(build): move the 404.html copy into scripts/copy-404.mjs Replaces the inline node -e one-liner in postbuild with a small commented script that explains why GitHub Pages needs the SPA fallback. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ppa5DyJCZ1CowKtz58QARN --- package.json | 2 +- scripts/copy-404.mjs | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 scripts/copy-404.mjs diff --git a/package.json b/package.json index ff17dc3..194d387 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "deploy": "gh-pages -d dist", "dev": "vite", "build": "vite build", - "postbuild": "node -e \"require('fs').copyFileSync('dist/index.html', 'dist/404.html')\"" + "postbuild": "node scripts/copy-404.mjs" }, "dependencies": { "@types/cytoscape": "^3.21.9", diff --git a/scripts/copy-404.mjs b/scripts/copy-404.mjs new file mode 100644 index 0000000..0e1a494 --- /dev/null +++ b/scripts/copy-404.mjs @@ -0,0 +1,6 @@ +// GitHub Pages serves 404.html for unknown paths. Copying the SPA entry point +// there lets BrowserRouter handle deep links and refreshes (e.g. /assets/projects). +import { copyFileSync } from 'node:fs'; + +copyFileSync('dist/index.html', 'dist/404.html'); +console.log('Copied dist/index.html to dist/404.html (SPA fallback)'); From ce5a6013e0bb8e4a1d6e574b283df7cdf26e0203 Mon Sep 17 00:00:00 2001 From: donbr <7340008+donbr@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:32:28 -0700 Subject: [PATCH 05/10] fix(nav): close the mobile menu when the viewport reaches lg Above 1024px the menu is hidden by lg:hidden but stayed logically open, so it reappeared expanded (aria-expanded=true) after rotating a tablet to landscape and back. A matchMedia listener, attached only while the menu is open, closes it when the viewport crosses into lg. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ppa5DyJCZ1CowKtz58QARN --- src/components/layout/Layout.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index b63957c..ba82772 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -62,6 +62,18 @@ const Layout: React.FC = ({ children, title }) => { return () => document.removeEventListener('keydown', handleKeyDown); }, [menuOpen]); + // Close the mobile menu when the viewport reaches the lg breakpoint (where it's hidden), + // so it doesn't reappear open after e.g. rotating a tablet to landscape and back + useEffect(() => { + if (!menuOpen) return; + const desktop = window.matchMedia('(min-width: 1024px)'); + const handleChange = (e: MediaQueryListEvent) => { + if (e.matches) setMenuOpenAt(null); + }; + desktop.addEventListener('change', handleChange); + return () => desktop.removeEventListener('change', handleChange); + }, [menuOpen]); + const isItemActive = (item: NavItem) => item.kind === 'hash' ? location.hash === item.hash From d7e38cf923896d296d909c7df7eaba7b9dfaa0fe Mon Sep 17 00:00:00 2001 From: donbr <7340008+donbr@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:12:04 -0700 Subject: [PATCH 06/10] fix(nav): reset the mobile menu on every navigation, not by history key Tying open state to the location.key it was opened at had two failures: the key was never cleared, so Back then Forward to that entry reopened the menu by itself, and location.key stays 'default' across a fresh load and native fragment navigations, so those didn't close it. The menu is now a plain open flag stamped with the navigation (pathname + search + hash + key) and reset during render when that changes. Escape returns focus to the toggle only when focus was inside the menu. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ppa5DyJCZ1CowKtz58QARN --- src/components/layout/Layout.tsx | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index ba82772..7edcf11 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -42,20 +42,25 @@ const navStyles = { const Layout: React.FC = ({ children, title }) => { const location = useLocation(); const menuButtonRef = useRef(null); - - // The menu is open only for the location it was opened at, so any navigation - // (link, back/forward, hash change) closes it without an extra effect - const [menuOpenAt, setMenuOpenAt] = useState(null); - const menuOpen = menuOpenAt === location.key; - const closeMenu = () => setMenuOpenAt(null); - - // Close the mobile menu on Escape and return focus to the toggle button + const menuRef = useRef(null); + + // Any navigation closes the menu: link, back/forward or a native hash change. The key + // includes pathname, search and hash because location.key alone can stay 'default' + // (initial load, native fragment navigation). Reset during render, not in an effect. + const navKey = `${location.pathname}${location.search}${location.hash}:${location.key}`; + const [menu, setMenu] = useState({ open: false, navKey }); + if (menu.navKey !== navKey) setMenu({ open: false, navKey }); + const menuOpen = menu.open && menu.navKey === navKey; + const setMenuOpen = (open: boolean) => setMenu({ open, navKey }); + const closeMenu = () => setMenuOpen(false); + + // Close the mobile menu on Escape; if focus was inside it, return focus to the toggle useEffect(() => { if (!menuOpen) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { - setMenuOpenAt(null); - menuButtonRef.current?.focus(); + if (menuRef.current?.contains(document.activeElement)) menuButtonRef.current?.focus(); + setMenu((m) => ({ ...m, open: false })); } }; document.addEventListener('keydown', handleKeyDown); @@ -68,7 +73,7 @@ const Layout: React.FC = ({ children, title }) => { if (!menuOpen) return; const desktop = window.matchMedia('(min-width: 1024px)'); const handleChange = (e: MediaQueryListEvent) => { - if (e.matches) setMenuOpenAt(null); + if (e.matches) setMenu((m) => ({ ...m, open: false })); }; desktop.addEventListener('change', handleChange); return () => desktop.removeEventListener('change', handleChange); @@ -136,14 +141,14 @@ const Layout: React.FC = ({ children, title }) => { aria-controls="mobile-menu" aria-expanded={menuOpen} aria-label={menuOpen ? 'Close menu' : 'Open menu'} - onClick={() => setMenuOpenAt(menuOpen ? null : location.key)} + onClick={() => setMenuOpen(!menuOpen)} > {menuOpen ?
{/* Mobile links: always rendered (hidden when closed) so the button's aria-controls target exists */} - From e663a615bf04187ee84ed05f72507c37fff82131 Mon Sep 17 00:00:00 2001 From: donbr <7340008+donbr@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:12:04 -0700 Subject: [PATCH 07/10] fix(nav): keep the query string and leave modified clicks alone on section links - navigate({ hash }) dropped the query string (/?utm_source=x -> /#about); it now passes search through. - Repeat clicks on the current section replace instead of stacking history. - Cmd/Ctrl/Shift/Alt and middle clicks (open in new tab) are left to the browser, as are links to sections not on the page; anchors are found with closest('a') and sections with getElementById (ids like '1-intro' aren't valid CSS selectors). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ppa5DyJCZ1CowKtz58QARN --- src/pages/HomePage.tsx | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index ee74cff..d1cb408 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -5,20 +5,25 @@ import Layout from '@/components/layout/Layout'; const HomePage: React.FC = () => { const navigate = useNavigate(); - // Handle smooth scrolling for anchor links + // Smooth-scroll in-page section links (e.g. /#about) useEffect(() => { const handleAnchorClick = (e: MouseEvent) => { - const target = e.target as HTMLAnchorElement; - if (target.hash && target.hash.startsWith('#')) { - e.preventDefault(); - const element = document.querySelector(target.hash); - if (element) { - element.scrollIntoView({ behavior: 'smooth' }); - // Update the URL through the router (not history.pushState) so location.hash, - // the nav's active state and back/forward stay in sync - navigate({ hash: target.hash }); - } - } + // Leave modified and middle clicks (open in new tab/window) to the browser + if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; + const anchor = (e.target as Element).closest('a'); + if (!anchor?.hash || anchor.pathname !== window.location.pathname) return; + // getElementById, not querySelector: ids like "1-intro" aren't valid CSS selectors + const element = document.getElementById(decodeURIComponent(anchor.hash.slice(1))); + if (!element) return; // unknown section: let the browser handle the link + e.preventDefault(); + element.scrollIntoView({ behavior: 'smooth' }); + // Update the URL through the router (not history.pushState) so location.hash, the + // nav's active state and back/forward stay in sync. Keep the query string (e.g. + // utm_* tags) and don't stack duplicate entries when the section is already current. + navigate( + { search: window.location.search, hash: anchor.hash }, + { replace: anchor.hash === window.location.hash } + ); }; document.addEventListener('click', handleAnchorClick); From 50761a3b6674b28f734a8b08297b0a1ef28f0300 Mon Sep 17 00:00:00 2001 From: donbr <7340008+donbr@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:12:04 -0700 Subject: [PATCH 08/10] fix(projects): don't highlight Deep Dive while hovering another card link group-hover on the card darkened 'Deep Dive' when the pointer was on Live Demo or Repository, suggesting those clicks go to the detail page. Deep Dive keeps a fixed color; the card's group class is no longer needed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ppa5DyJCZ1CowKtz58QARN --- src/pages/ProjectsPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/ProjectsPage.tsx b/src/pages/ProjectsPage.tsx index adb1220..92e684f 100644 --- a/src/pages/ProjectsPage.tsx +++ b/src/pages/ProjectsPage.tsx @@ -73,7 +73,7 @@ const ProjectCard: React.FC = ({ }; return ( -
+

{detailUrl ? ( // Stretched link: the ::after overlay makes the whole card clickable without nesting links @@ -104,7 +104,7 @@ const ProjectCard: React.FC = ({ // isn't a second link (avoids a duplicate tab stop to the same destination) From b678ab59c6a04ea50fff4e7b50894cdfd189ab72 Mon Sep 17 00:00:00 2001 From: donbr <7340008+donbr@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:12:04 -0700 Subject: [PATCH 09/10] fix(build): resolve copy-404 paths from the script's location It resolved dist/ against the working directory, so running it from anywhere but the repo root threw ENOENT. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ppa5DyJCZ1CowKtz58QARN --- scripts/copy-404.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/copy-404.mjs b/scripts/copy-404.mjs index 0e1a494..c1a338c 100644 --- a/scripts/copy-404.mjs +++ b/scripts/copy-404.mjs @@ -1,6 +1,8 @@ // GitHub Pages serves 404.html for unknown paths. Copying the SPA entry point // there lets BrowserRouter handle deep links and refreshes (e.g. /assets/projects). +// Paths resolve from this script's location, so it works from any working directory. import { copyFileSync } from 'node:fs'; -copyFileSync('dist/index.html', 'dist/404.html'); +const dist = new URL('../dist/', import.meta.url); +copyFileSync(new URL('index.html', dist), new URL('404.html', dist)); console.log('Copied dist/index.html to dist/404.html (SPA fallback)'); From 4702585e8a1a628aa740388eb851259576769a75 Mon Sep 17 00:00:00 2001 From: donbr <7340008+donbr@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:29:01 -0700 Subject: [PATCH 10/10] fix(nav): only intercept same-origin, same-tab section links The smooth-scroll handler compared pathnames only, so an external link such as https://example.com/#contact (or any target=_blank link) whose path and hash matched a homepage section would scroll locally instead of opening. It now also requires the same origin and no target other than _self. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ppa5DyJCZ1CowKtz58QARN --- src/pages/HomePage.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index d1cb408..1e82181 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -11,7 +11,13 @@ const HomePage: React.FC = () => { // Leave modified and middle clicks (open in new tab/window) to the browser if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; const anchor = (e.target as Element).closest('a'); - if (!anchor?.hash || anchor.pathname !== window.location.pathname) return; + // Only same-page section links: not other origins, other paths, or new-tab targets + if ( + !anchor?.hash || + anchor.origin !== window.location.origin || + anchor.pathname !== window.location.pathname || + (anchor.target && anchor.target !== '_self') + ) return; // getElementById, not querySelector: ids like "1-intro" aren't valid CSS selectors const element = document.getElementById(decodeURIComponent(anchor.hash.slice(1))); if (!element) return; // unknown section: let the browser handle the link