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

8.7 KiB
Raw Blame History

Gravity Engine Fix 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 lag and container-blowout bugs in GravityEngine, and add a spring-curve glide-back animation when gravity is toggled off.

Architecture: Cache element dimensions at start() to eliminate per-frame layout reflow. Track every element whose inline styles are modified in a dirtyElements array so stop() can fully restore the DOM. On deactivation, animate leaves back to their origin positions via a CSS transition before clearing all styles.

Tech Stack: TypeScript, browser DOM APIs, CSS transitions (cubic-bezier)


Files

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

Task 1: Extend PhysicsBody and add dirtyElements to GravityEngine

Files:

  • Modify: lib/gravity-engine.ts:1-17

  • Step 1: Replace the PhysicsBody interface and class fields

Open lib/gravity-engine.ts. Replace lines 117 with:

interface PhysicsBody {
  el: HTMLElement;
  x: number;
  y: number;
  vx: number;
  vy: number;
  width: number;
  height: number;
  originX: number;
  originY: number;
  isDragging: boolean;
  startX: number;
  startY: number;
}

export class GravityEngine {
  private bodies: PhysicsBody[] = [];
  private dirtyElements: HTMLElement[] = [];
  private animationFrameId: number | null = null;
  private isRunning = false;

  INITIAL_FORCE = 10;
  • Step 2: Verify TypeScript compiles
npx tsc --noEmit

Expected: no errors.

  • Step 3: Commit
git add lib/gravity-engine.ts
git commit -m "refactor: extend PhysicsBody with cached dims and add dirtyElements"

Task 2: Refactor start() into three ordered phases

Files:

  • Modify: lib/gravity-engine.ts — the start() method

  • Step 1: Replace the entire start() method

Find and replace the start() method (currently lines 1996) with:

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

    const containers = document.querySelectorAll(".gravity-body");
    const leafElements: HTMLElement[] = [];

    // Phase 1 — Size-lock: freeze intermediate container dimensions
    // so they don't collapse when children become position:fixed.
    const extractLeaves = (el: HTMLElement) => {
      const isSolid = ["SVG", "IMG", "BUTTON", "IFRAME", "A"].includes(
        el.tagName.toUpperCase(),
      );

      if (isSolid || el.children.length === 0) {
        if (el.offsetWidth > 0 && el.offsetHeight > 0) {
          leafElements.push(el);
        }
        return;
      }

      el.style.width = `${el.offsetWidth}px`;
      el.style.height = `${el.offsetHeight}px`;
      this.dirtyElements.push(el);

      Array.from(el.children).forEach((child) =>
        extractLeaves(child as HTMLElement),
      );
    };

    containers.forEach((container) => {
      const htmlContainer = container as HTMLElement;
      htmlContainer.style.width = `${htmlContainer.offsetWidth}px`;
      htmlContainer.style.height = `${htmlContainer.offsetHeight}px`;
      this.dirtyElements.push(htmlContainer);

      Array.from(htmlContainer.children).forEach((child) =>
        extractLeaves(child as HTMLElement),
      );
    });

    // Phase 2 — Read: batch all getBoundingClientRect() before any writes.
    const snapshots = leafElements.map((el) => ({
      el,
      rect: el.getBoundingClientRect(),
    }));

    // Phase 3 — Write: apply fixed positioning using snapshotted rects.
    this.bodies = snapshots.map(({ el, rect }) => {
      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)`;
      this.dirtyElements.push(el);

      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 2: Verify TypeScript compiles
npx tsc --noEmit

Expected: no errors.

  • Step 3: Commit
git add lib/gravity-engine.ts
git commit -m "refactor: split start() into size-lock, read, write phases"

Task 3: Fix tick() — use cached dimensions, remove redundant style sets

Files:

  • Modify: lib/gravity-engine.ts — the tick arrow function

  • Step 1: Replace the tick method

Find and replace the private tick = () => { ... }; block with:

  private tick = () => {
    if (!this.isRunning) return;

    const gravity = 0.5;
    const bounce = -0.7;
    const floorY = window.innerHeight;

    this.bodies.forEach((body) => {
      if (body.isDragging) {
        body.el.style.transform = `translate(${body.x}px, ${body.y}px)`;
        return;
      }

      body.vy += gravity;
      body.x += body.vx;
      body.y += body.vy;

      body.vx *= 0.99;
      body.vy *= 0.99;

      if (body.y + body.height > floorY) {
        body.y = floorY - body.height;
        body.vy *= bounce;
        body.vx *= 0.9;
      }

      body.el.style.transform = `translate(${body.x}px, ${body.y}px)`;
    });

    this.animationFrameId = requestAnimationFrame(this.tick);
  };
  • Step 2: Verify TypeScript compiles
npx tsc --noEmit

Expected: no errors.

  • Step 3: Commit
git add lib/gravity-engine.ts
git commit -m "perf: use cached body dimensions in tick, remove redundant style sets"

Task 4: Implement stop() with glide-back animation and dirty-set cleanup

Files:

  • Modify: lib/gravity-engine.ts — the stop() method

  • Step 1: Replace the stop() method

Find and replace the entire 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, clear all inline styles from every
    // element we touched so the document flow is fully restored.
    const elementsToClear = [...this.dirtyElements];
    this.bodies = [];
    this.dirtyElements = [];

    setTimeout(() => {
      elementsToClear.forEach((el) => {
        el.style.transform = "";
        el.style.transition = "";
        el.style.position = "";
        el.style.left = "";
        el.style.top = "";
        el.style.width = "";
        el.style.height = "";
        el.style.margin = "";
      });
    }, DURATION);
  }
  • Step 2: Verify TypeScript compiles
npx tsc --noEmit

Expected: no errors.

  • Step 3: Commit
git add lib/gravity-engine.ts
git commit -m "feat: glide-back animation on stop, full dirty-set cleanup"

Task 5: Visual verification

Files: none — browser testing only

  • Step 1: Start the dev server
npm run dev

Open http://localhost:3000 in a browser.

  • Step 2: Test activation

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

  • Elements fall and bounce with no visible lag

  • Page containers do not collapse or change size while gravity is active

  • Elements can be dragged and thrown

  • Step 3: Test deactivation

Click the toggle again. Verify:

  • All elements glide back to their original positions with a spring curve (~600ms)

  • After the animation, the page looks exactly like it did before gravity was activated (no leftover inline styles, no layout shift)

  • Toggling on/off multiple times works correctly each time

  • Step 4: Test the about page

Navigate to http://localhost:3000/about, repeat the activation/deactivation test. The about page also has a .gravity-body container with more complex nested layout (flex, prose, grid sections) — verify it also restores cleanly.

  • Step 5: Commit if any fixes were needed, otherwise done
git add lib/gravity-engine.ts
git commit -m "fix: address visual issues found during gravity testing"

(Skip this step if no issues were found.)