diff --git a/frontend/libs/components/pagescene/src/lib/engine/timeline/interpolation.spec.ts b/frontend/libs/components/pagescene/src/lib/engine/timeline/interpolation.spec.ts
new file mode 100644
--- /dev/null
+++ b/frontend/libs/components/pagescene/src/lib/engine/timeline/interpolation.spec.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from "vitest";
+import { cubicBezierYForX, sampleTrackAt } from "./interpolation";
+import { EASING_PRESETS, type EasingSpec, type TimelineTrack } from "./types";
+
+const FLAT_START: EasingSpec = { p1x: 0, p1y: 1 / 3, p2x: 0, p2y: 2 / 3 };
+const FLAT_END: EasingSpec = { p1x: 1, p1y: 1 / 3, p2x: 1, p2y: 2 / 3 };
+
+describe("cubicBezierYForX", () => {
+  it.each([0.000001, 0.0001, 0.001, 0.01, 0.1, 0.5])(
+    "solves the flat-start curve at %s without snapping to its end",
+    (x) => {
+      // For these control points X(t) = t^3 and Y(t) = t.
+      expect(cubicBezierYForX(FLAT_START, x)).toBeCloseTo(Math.cbrt(x), 6);
+    },
+  );
+
+  it.each([0.5, 0.9, 0.99, 0.999, 0.9999, 0.999999])(
+    "solves the flat-end curve at %s without snapping to its start",
+    (x) => {
+      expect(cubicBezierYForX(FLAT_END, x)).toBeCloseTo(1 - Math.cbrt(1 - x), 6);
+    },
+  );
+
+  it.each(Object.values(EASING_PRESETS))("preserves endpoints for %j", (curve) => {
+    expect(cubicBezierYForX(curve, 0)).toBe(0);
+    expect(cubicBezierYForX(curve, 1)).toBe(1);
+  });
+
+  it.each([0.001, 0.25, 0.5, 0.75, 0.999])("keeps linear progress at %s", (x) => {
+    expect(cubicBezierYForX(EASING_PRESETS.linear, x)).toBeCloseTo(x, 5);
+  });
+
+  it("uses the solved curve when sampling an object transform", () => {
+    const track: TimelineTrack = {
+      objectUuid: "animated-object",
+      keyframes: [
+        { id: "start", time: 0, easing: FLAT_START, transform: {
+          position: { x: 0, y: 0, z: 0 }, rotation: { x: 0, y: 0, z: 0 }, scale: { x: 1, y: 1, z: 1 },
+        } },
+        { id: "end", time: 10, easing: EASING_PRESETS.linear, transform: {
+          position: { x: 100, y: 0, z: 0 }, rotation: { x: 1, y: 0, z: 0 }, scale: { x: 2, y: 1, z: 1 },
+        } },
+      ],
+    };
+    const sample = sampleTrackAt(track, 0.01)!;
+    expect(sample.position.x).toBeCloseTo(10, 5);
+    expect(sample.rotation.x).toBeCloseTo(0.1, 6);
+    expect(sample.scale.x).toBeCloseTo(1.1, 6);
+  });
+});
diff --git a/frontend/libs/components/pagescene/src/lib/engine/timeline/interpolation.ts b/frontend/libs/components/pagescene/src/lib/engine/timeline/interpolation.ts
--- a/frontend/libs/components/pagescene/src/lib/engine/timeline/interpolation.ts
+++ b/frontend/libs/components/pagescene/src/lib/engine/timeline/interpolation.ts
@@ -26,14 +26,21 @@ export const cubicBezierYForX = (easing: EasingSpec, x: number): number => {
   if (x <= 0) return 0;
   if (x >= 1) return 1;
   let t = x;
-  for (let i = 0; i < 8; i++) {
+  let lower = 0;
+  let upper = 1;
+  for (let i = 0; i < 32; i++) {
     const err = bezierAxis(t, easing.p1x, easing.p2x) - x;
-    if (Math.abs(err) < 1e-5) break;
+    if (err === 0) break;
+    if (err < 0) lower = t;
+    else upper = t;
+    if (upper - lower < 1e-8) break;
+
     const d = bezierAxisDerivative(t, easing.p1x, easing.p2x);
-    if (Math.abs(d) < 1e-6) break;
-    t -= err / d;
+    const next = Math.abs(d) > 1e-6 ? t - err / d : NaN;
+    // Flat handles can send Newton outside the curve. Keep a bracket and
+    // bisect whenever its next estimate would leave the valid interval.
+    t = next > lower && next < upper ? next : (lower + upper) / 2;
   }
-  t = Math.max(0, Math.min(1, t));
   return bezierAxis(t, easing.p1y, easing.p2y);
 };
 
