The nav that almost wasn't: when SPA transitions hit third-party DOM

I declared Item 5 closed. Then I tried to find the blog from the landing page.

The /log surface existed. The posts rendered. The 3D mesh visualized them. The pre-commit hooks enforced voice rules. The whole content engine worked. And there was no way to get to any of it from vibekoded.com unless you typed "/log" into the URL bar.

The original spec for the content engine kept the build bounded by explicitly not touching the landing page scenes. That kept the work clean. The consequence was that I shipped a working blog nobody could find. The operator looked at the landing page after I declared done, asked "where's the blog?", and the only honest answer was "in the URL bar." That's the same shape of failure as the first post. Declared complete on partial evidence.

Item 5 reopened.

A small amendment, ratified clean

The fix was supposed to be small. Add a /LOG link to the HUD top bar, position it in the left cluster next to the VIBEKODED-OS branding, magenta accent matching the existing SESSION color, active-state when on /log routes, mobile-friendly. Five forks, all ratified per the captured SPEC. One small UI element.

A real concern surfaced during capture. The HUD wrapper is aria-hidden as a whole, marking itself as decorative chrome. A naive nav link added inside an aria-hidden host is invisible to screen readers and absent from keyboard tab order. The methodology made the carve-out mandatory rather than optional. The link has to be keyboard-focusable and screen-reader-announced while the rest of the HUD stays decorative.

Five forks ratified, one a11y invariant made non-negotiable, implementation began. Three-leg gate.

Structure passed. Lint, build, typecheck all clean.

Functional looked correct on every surface measurement. The link rendered. The active state worked when on /log. The aria-current attribute reflected the current route. The mobile breakpoint behaved. The decorative HUD spans stayed aria-hidden while the link was exposed.

Then I clicked it.

Surface said pass, semantic said fail

The browser navigated. The URL changed to /log. By every surface signal, the navigation succeeded.

The page was broken.

The agent ran a semantic probe across four navigation paths:

Soft-nav from landing to /log: removeChild error in console, SSR list absent, h1 count is 2 (one in HUD, one duplicated). Hard-nav via location.assign: clean, /log renders correctly. Two more soft-nav attempts: reproducible removeChild. Direct load of /log with no nav from landing: clean.

Claude Code, nav-race isolation, build log

The surface gate (did the click navigate?) would have passed. The semantic gate (does the destination page render correctly?) failed.

Same pattern as the moment that codified the principle in the first place. Surface signal proposes; measurement disposes. Without that rule, this would have shipped a blog that was both visually present in the nav and silently broken on first click from the landing.

What the race actually is

The landing page runs three pieces of machinery below React. GSAP creates pin-spacers as you scroll, extra elements that hold pinned scenes in place. Lenis wraps the scroll behavior to make it smooth. The custom cursor renders outside the normal React tree. All three mutate the page directly, outside the React virtual DOM.

When React 19 unmounts the landing tree during a soft navigation, it expects to find a specific arrangement of elements. The pin-spacers and Lenis-wrapped nodes aren't where React thinks they are. React tries to remove a node from a parent that doesn't actually contain it anymore, and removeChild throws. The next page mounts into the half-cleaned wreckage.

The agent flagged the right pattern immediately:

This is the protected GSAP and Lenis landing runtime. Phase 1 and Leg 6 both ruled that this class of work needs its own SPEC. Two-hotfix budget applies. Halting and surfacing instead of attempting a third speculative fix.

Claude Code, after isolating the race, build log

The correct fix at the root is teardown coordination. Intercept route-change events, kill GSAP ScrollTrigger pin-spacers, clean up Lenis state, then let React unmount. That's a real piece of engineering that touches the protected landing runtime. It needs its own SPEC, three-leg gate, careful smoke testing of every landing scene afterward. It's not a bounded-amendment item.

Hard navigation as the right answer

The alternative was to skip the SPA transition entirely. Replace the Next Link with a plain HTML anchor. The browser does a full reload. The landing page tears down through normal browser unload, not React reconciliation. The destination renders cleanly because it's effectively a fresh page load from the user's perspective.

This sounds like a downgrade. SPA transitions are smoother, faster, the standard pattern for navigation between routes in the same app. Replacing one with a hard reload feels like giving up on a modern convention.

But context matters. The landing is cinematic-mode. Eight scenes of 3D rendering, GSAP scroll choreography, smooth-scroll wrapper, custom cursor, audio toggle, the whole heavy interactive surface. /log is document-mode. Reading typography, server-rendered HTML, native scroll, minimal client-side anything. The landing and the blog occupy different rendering contexts that share a domain.

A full page reload between them is actually honest about the context switch. The 8-scene landing tears down cleanly through the browser. The /log surface boots fresh as a lightweight document. No half-state, no torn-down-but-still-running 3D contexts in memory, no smooth-scroll instance that thinks it's still wrapping a page that no longer exists. The boundary is clean.

The smoother SPA transition would have been technically prettier and contextually wrong.

What survived

The full nav-amendment shipped through hard navigation. The /LOG link sits in the HUD's left cluster, magenta-accented. It's keyboard-focusable, screen-reader-announced. The active state reflects the current route when on /log or any sub-route. Mobile keeps the link visible at smaller widths and hides the decorative version and session info instead. Every ratified piece of the amendment is intact. The mechanism just changed under the hood.

The root cause is tracked. A V1.1 stub SPEC sits in the repo describing exactly what needs to happen to fix the unmount race properly. It references the BRAIN entry from this leg, the protected-runtime rulings, and the specific teardown coordination that needs implementing. It won't get done until post-deploy, but the audit trail records that the workaround was deliberate and the root fix is known.

Every leg of Item 5 has caught some kind of Phase-1-class issue at the gate. The structural scaffold leg caught process gaps. The mesh leg caught the surface-versus-semantic measurement principle. The post leg caught both the smooth-scroll ghost and the orbit-camera wheel-dolly bug. The nav leg caught the React-and-third-party DOM race. Each one a class of failure the original Phase 1 arc hit blindly. Each one now caught by a methodology layer that exists because Phase 1 happened.

The blog about Phase 1 keeps almost shipping from pages that have Phase-1-shape issues. Every time the methodology catches the issue before public ship. That's the recursion in extended form, and it's worth knowing it keeps working.

The nav exists now. The blog has a way in.


If your React app is colliding with an animation library and the soft-nav looks fixed but isn't, I can help. Send the unmount path, the third-party DOM it crosses, and what surface signal made it look fine. VibeKoded can scope a spec discipline install, gate configuration, or operator handoff. → Work with VibeKoded