Files
gabrielkaszewski-next/docs/superpowers/plans/2026-04-24-gravity-bug-fixes.md

8.1 KiB

Gravity Engine Bug Fixes Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Fix three root-cause bugs in GravityEngine: too many physics bodies (lag), Phase 1 layout thrashing (startup freeze), and inline style destruction on restore (image breakage).

Architecture: All three fixes are in lib/gravity-engine.ts. The dirtyElements field changes from HTMLElement[] to Array<{el, originalCssText}> so stop() restores exact pre-gravity inline styles (fixing image breakage). start() is rewritten with a clean read-then-write two-pass approach and a MAX_DEPTH = 3 depth cap so sections/cards become physics units instead of hundreds of atomic text nodes (fixing lag and startup freeze). No other files change.

Tech Stack: TypeScript, browser DOM APIs


Files

  • Modify: lib/gravity-engine.ts — only file changed

Task 1: Rewrite start() and stop() with all three fixes

Files:

  • Modify: lib/gravity-engine.ts

  • Step 1: Read the current file

cat /mnt/drive/dev/gabrielkaszewski-next/lib/gravity-engine.ts

Confirm it starts with the PhysicsBody interface and GravityEngine class with bodies, dirtyElements, animationFrameId, isRunning, and INITIAL_FORCE fields.

  • Step 2: Replace the dirtyElements field

Find this line in GravityEngine:

  private dirtyElements: HTMLElement[] = [];

Replace with:

  private dirtyElements: Array<{ el: HTMLElement; originalCssText: string }> = [];
  • Step 3: Replace the entire start() method

Find and replace the full start() method with:

  start() {
    if (this.isRunning) return;
    this.isRunning = true;
    this.dirtyElements = [];

    const MAX_DEPTH = 3;
    const containers = document.querySelectorAll(".gravity-body");
    const leafElements: HTMLElement[] = [];
    const intermediates: { el: HTMLElement; w: number; h: number }[] = [];

    // READ-ONLY traversal: classify all elements and snapshot sizes.
    // No DOM writes here — one layout flush for the entire traversal.
    const collectElements = (el: HTMLElement, depth: number) => {
      // Skip purely decorative elements (e.g. background images).
      if (el.classList.contains("pointer-events-none")) return;

      const isSolid = ["SVG", "IMG", "BUTTON", "IFRAME", "A"].includes(
        el.tagName.toUpperCase(),
      );

      // Treat as a leaf if solid, childless, or at the depth cap.
      if (isSolid || el.children.length === 0 || depth >= MAX_DEPTH) {
        if (el.offsetWidth > 0 && el.offsetHeight > 0) {
          leafElements.push(el);
        }
        return;
      }

      intermediates.push({ el, w: el.offsetWidth, h: el.offsetHeight });
      Array.from(el.children).forEach((child) =>
        collectElements(child as HTMLElement, depth + 1),
      );
    };

    const rootSizes: { el: HTMLElement; w: number; h: number }[] = [];
    containers.forEach((container) => {
      const htmlContainer = container as HTMLElement;
      rootSizes.push({
        el: htmlContainer,
        w: htmlContainer.offsetWidth,
        h: htmlContainer.offsetHeight,
      });
      Array.from(htmlContainer.children).forEach((child) =>
        collectElements(child as HTMLElement, 0),
      );
    });

    // WRITE PASS 1: size-lock roots and intermediates so they don't collapse
    // when their children become position:fixed.
    [...rootSizes, ...intermediates].forEach(({ el, w, h }) => {
      this.dirtyElements.push({ el, originalCssText: el.style.cssText });
      el.style.width = `${w}px`;
      el.style.height = `${h}px`;
    });

    // READ PASS 2: batch all getBoundingClientRect calls before any leaf writes.
    const snapshots = leafElements.map((el) => ({
      el,
      rect: el.getBoundingClientRect(),
    }));

    // WRITE PASS 2: apply fixed positioning to leaves using snapshotted rects.
    this.bodies = snapshots.map(({ el, rect }) => {
      this.dirtyElements.push({ el, originalCssText: el.style.cssText });
      el.style.width = `${rect.width}px`;
      el.style.height = `${rect.height}px`;
      el.style.margin = "0px";
      el.style.position = "fixed";
      el.style.left = "0px";
      el.style.top = "0px";
      el.style.transform = `translate(${rect.left}px, ${rect.top}px)`;

      const body: PhysicsBody = {
        el,
        x: rect.left,
        y: rect.top,
        vx: (Math.random() - 0.5) * 8,
        vy: (Math.random() - 0.5) * 5,
        width: rect.width,
        height: rect.height,
        originX: rect.left,
        originY: rect.top,
        isDragging: false,
        startX: 0,
        startY: 0,
      };

      this.attachMouseEvents(body);
      return body;
    });

    this.tick();
  }
  • Step 4: Replace the entire stop() method

Find and replace the full stop() method with:

  stop() {
    this.isRunning = false;
    if (this.animationFrameId) {
      cancelAnimationFrame(this.animationFrameId);
      this.animationFrameId = null;
    }

    const DURATION = 600;

    // Glide each body back to its origin position.
    this.bodies.forEach((body) => {
      body.el.style.transition = `transform ${DURATION}ms cubic-bezier(0.22, 1, 0.36, 1)`;
      body.el.style.transform = `translate(${body.originX}px, ${body.originY}px)`;
    });

    // After the transition completes, restore every element's original inline
    // styles so document flow is fully recovered — including elements that had
    // pre-existing inline styles we must not erase (e.g. Next.js fill images).
    const elementsToClear = [...this.dirtyElements];
    this.bodies = [];
    this.dirtyElements = [];

    setTimeout(() => {
      elementsToClear.forEach(({ el, originalCssText }) => {
        el.style.cssText = originalCssText;
      });
    }, DURATION);
  }
  • Step 5: Verify TypeScript compiles with no errors
cd /mnt/drive/dev/gabrielkaszewski-next && npx tsc --noEmit

Expected: no errors.

  • Step 6: Commit
git -C /mnt/drive/dev/gabrielkaszewski-next add lib/gravity-engine.ts
git -C /mnt/drive/dev/gabrielkaszewski-next commit -m "fix: two-pass start, depth cap, cssText restore on stop"

Task 2: Visual verification

Files: none — browser testing only

  • Step 1: Start the dev server
cd /mnt/drive/dev/gabrielkaszewski-next && npm run dev

Open http://localhost:3000.

  • Step 2: Test home page — activation

Click the gravity toggle (bottom-right, yellow). Verify:

  • No freeze or stutter at the moment of activation

  • Elements fall and bounce without visible lag

  • Hero section text/links fall; background image does NOT move (excluded via pointer-events-none class)

  • Step 3: Test home page — deactivation

Click toggle again. Verify:

  • Elements glide back with spring curve (~600ms)

  • Page returns to its original layout — no broken images, no collapsed sections, no leftover inline styles

  • Hero background image is still correctly filling the section

  • Step 4: Test projects page

Navigate to http://localhost:3000/projects. Toggle gravity on/off. Verify:

  • Activation is instant (no freeze)

  • Animation runs smoothly — project entries fall as chunked units (left panel + image panel), not as hundreds of individual text nodes

  • Deactivation restores layout cleanly

  • Step 5: Test about page

Navigate to http://localhost:3000/about. Toggle gravity on/off. Verify:

  • Profile photo glides back and renders correctly after restore

  • All sections (hobbies, toolkit, FAQ) restore to original layout

  • Step 6: Toggle multiple times

On each page, toggle gravity on/off 3 times in succession. Verify no accumulated style corruption across cycles.

  • Step 7: Commit any fixes found, or mark done

If visual issues were found and fixed during testing:

git -C /mnt/drive/dev/gabrielkaszewski-next add lib/gravity-engine.ts
git -C /mnt/drive/dev/gabrielkaszewski-next commit -m "fix: address visual issues found during gravity verification"

If no issues found: skip this step.