fix(core): draw the dim baseline only from tweens the guess still applies to

Review catch. The baseline was taken from the first colour tween unconditionally,
so a tween declared "active" at index 0 set the reference its undeclared siblings
were compared against -- and the genuinely dim tween beside it was classified
active and given the wrong override. A partial migration could therefore end up
worse off than a composition that declared nothing.

The reference is now a declared "dim" tween if one exists, else the first
undeclared one: the heuristic stops drawing its inputs from records the
declaration has already spoken to.

Also pins the fallback for a malformed declaration -- a typo, a number, a null, or
a non-object `data` -- so a future tightening of the accepted union cannot quietly
turn an unrecognised value into a broken composition.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-07 20:14:51 -07:00
co-authored by Claude Opus 5
parent 1e7799bfbb
commit cef0dde5f2
2 changed files with 65 additions and 9 deletions
@@ -183,4 +183,55 @@ describe("caption state declaration", () => {
expect(tweens[0].color).toBe("#eee");
expect(tweens[1].color).toBe("#111");
});
it("does not let a declared tween poison the baseline for undeclared ones", async () => {
// The baseline exists to guess about tweens the declaration has NOT spoken to. Deriving it from
// a tween declared "active" makes every undeclared same-state tween compare against a colour
// that has explicitly said it is not the dim reference — so the one genuinely dim tween here
// gets classified active and receives the wrong override.
const tweens = installGsapMockWithTweens([
{ color: "#eee", data: { captionState: "active" } },
{ color: "#111" },
]);
installCaptionOverrideFetch([{ wordIndex: 0, dimColor: "#dim", activeColor: "#active" }]);
document.body.innerHTML = `<div class="caption-group"><span id="w0">Hi</span></div>`;
applyCaptionOverrides();
await flushCaptionOverrides();
expect(tweens[0].color).toBe("#active");
expect(tweens[1].color).toBe("#dim");
});
it("prefers a declared dim tween as the baseline over an undeclared one", async () => {
const tweens = installGsapMockWithTweens([
{ color: "#aaa" },
{ color: "#bbb", data: { captionState: "dim" } },
]);
installCaptionOverrideFetch([{ wordIndex: 0, dimColor: "#dim", activeColor: "#active" }]);
document.body.innerHTML = `<div class="caption-group"><span id="w0">Hi</span></div>`;
applyCaptionOverrides();
await flushCaptionOverrides();
// #bbb is the declared dim reference, so the undeclared #aaa is not dim.
expect(tweens[0].color).toBe("#active");
expect(tweens[1].color).toBe("#dim");
});
it("falls through to the heuristic for a malformed declaration", async () => {
// A typo, a primitive, or a null must never ship a broken composition — an unrecognised value
// is not a state, so classification continues as if nothing was declared.
for (const data of [{ captionState: "typo" }, { captionState: 42 }, null, "notAnObject"]) {
const tweens = installGsapMockWithTweens([{ color: "#222", data }, { color: "#fff" }]);
installCaptionOverrideFetch([{ wordIndex: 0, dimColor: "#dim", activeColor: "#active" }]);
document.body.innerHTML = `<div class="caption-group"><span id="w0">Hi</span></div>`;
applyCaptionOverrides();
await flushCaptionOverrides();
expect(tweens[0].color).toBe("#dim");
expect(tweens[1].color).toBe("#active");
}
});
});
+14 -9
View File
@@ -138,21 +138,26 @@ export function applyCaptionOverrides(): void {
if (override.fontWeight !== undefined) styleProps.fontWeight = override.fontWeight;
if (override.fontFamily !== undefined) styleProps.fontFamily = override.fontFamily;
// Replace color values in existing GSAP tweens.
// Instead of relying on timeline position order (fragile if custom
// color tweens exist), we classify each tween by comparing its
// target color to the current computed color of the element.
// Tweens that match the current color are "dim" tweens; tweens
// with a different color are "active" tweens.
// Replace color values in existing GSAP tweens, classified in two layers.
//
// A tween that DECLARES its state is taken at its word. Anything undeclared falls back to
// colour equality against a dim reference — a guess, and the reason the declaration exists:
// two states sharing a colour make every tween look dim.
//
// The reference is drawn only from tweens the guess still applies to (a declared "dim" one
// if present, else the first undeclared one). Deriving it from a tween declared "active"
// would compare undeclared siblings against a colour that has explicitly said it is not the
// dim reference.
if (override.activeColor || override.dimColor) {
const allTweens = gsap.getTweensOf(el);
const colorTweens = allTweens
.filter((tw) => tw.vars.color !== undefined)
.sort((a, b) => a.startTime() - b.startTime());
// Use the first tween's color as the dim baseline — if no tweens,
// fall back to computed style.
const dimBaseline = colorTweens[0] ? String(colorTweens[0].vars.color) : "";
const dimReference =
colorTweens.find((tw) => declaredCaptionState(tw) === "dim") ??
colorTweens.find((tw) => declaredCaptionState(tw) === undefined);
const dimBaseline = dimReference ? String(dimReference.vars.color) : "";
for (const tw of colorTweens) {
// A declaration wins over the colour guess, per tween, so a composition can declare