8.7 KiB
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
PhysicsBodyinterface and class fields
Open lib/gravity-engine.ts. Replace lines 1–17 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— thestart()method -
Step 1: Replace the entire
start()method
Find and replace the start() method (currently lines 19–96) 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— thetickarrow function -
Step 1: Replace the
tickmethod
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— thestop()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.)