diff --git a/packages/core/src/runtime/captionOverrides.test.ts b/packages/core/src/runtime/captionOverrides.test.ts index 15ff8c0506..e62cf5f5a2 100644 --- a/packages/core/src/runtime/captionOverrides.test.ts +++ b/packages/core/src/runtime/captionOverrides.test.ts @@ -34,6 +34,19 @@ function installGsapMock() { return { setCalls }; } +/** A gsap mock whose `getTweensOf` returns the supplied colour tweens, so classification is testable. */ +function installGsapMockWithTweens(tweens: Array>) { + const gsap = { + set() {}, + killTweensOf() {}, + getTweensOf() { + return tweens.map((vars, i) => ({ vars, startTime: () => i })); + }, + }; + Object.defineProperty(window, "gsap", { configurable: true, value: gsap }); + return tweens; +} + async function flushCaptionOverrides() { for (let i = 0; i < 4; i++) { await Promise.resolve(); @@ -122,3 +135,103 @@ describe("applyCaptionOverrides", () => { expect(setCalls[0]?.vars).toEqual({ x: 16 }); }); }); + +describe("caption state declaration", () => { + it("honours a declared state when dim and active colours are IDENTICAL", async () => { + // The failure the declaration exists for. Classification by colour equality takes the first + // tween's colour as the dim baseline, so a composition whose two states share a colour has + // every tween classified dim — and `activeColor` is silently dropped. + const tweens = installGsapMockWithTweens([ + { color: "#888", data: { captionState: "dim" } }, + { color: "#888", data: { captionState: "active" } }, + ]); + installCaptionOverrideFetch([{ wordIndex: 0, dimColor: "#111", activeColor: "#eee" }]); + document.body.innerHTML = `
Hi
`; + + applyCaptionOverrides(); + await flushCaptionOverrides(); + + expect(tweens[0].color).toBe("#111"); + expect(tweens[1].color).toBe("#eee"); + }); + + it("falls back to colour classification when nothing is declared", async () => { + // Undeclared compositions must keep working exactly as before — the declaration is additive. + const tweens = installGsapMockWithTweens([{ color: "#222" }, { color: "#fff" }]); + installCaptionOverrideFetch([{ wordIndex: 0, dimColor: "#111", activeColor: "#eee" }]); + document.body.innerHTML = `
Hi
`; + + applyCaptionOverrides(); + await flushCaptionOverrides(); + + expect(tweens[0].color).toBe("#111"); + expect(tweens[1].color).toBe("#eee"); + }); + + it("prefers the declaration over the colour heuristic when they disagree", async () => { + // Declared order is deliberately the reverse of what colour-equality would infer. + const tweens = installGsapMockWithTweens([ + { color: "#222", data: { captionState: "active" } }, + { color: "#fff", data: { captionState: "dim" } }, + ]); + installCaptionOverrideFetch([{ wordIndex: 0, dimColor: "#111", activeColor: "#eee" }]); + document.body.innerHTML = `
Hi
`; + + applyCaptionOverrides(); + await flushCaptionOverrides(); + + 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 = `
Hi
`; + + 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 = `
Hi
`; + + 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 = `
Hi
`; + + applyCaptionOverrides(); + await flushCaptionOverrides(); + + expect(tweens[0].color).toBe("#dim"); + expect(tweens[1].color).toBe("#active"); + } + }); +}); diff --git a/packages/core/src/runtime/captionOverrides.ts b/packages/core/src/runtime/captionOverrides.ts index e89e557a81..1cd713b379 100644 --- a/packages/core/src/runtime/captionOverrides.ts +++ b/packages/core/src/runtime/captionOverrides.ts @@ -38,6 +38,23 @@ interface GsapStatic { getTweensOf: (target: Element) => GsapTween[]; } +/** + * The caption state a composition DECLARES for a colour tween, if any. + * + * Authored as `data: { captionState: "dim" | "active" }` in the tween's vars. GSAP passes unknown + * vars through untouched, so this costs a declaring composition nothing at runtime. + * + * It exists because the fallback below has to GUESS. Classifying by colour equality breaks outright + * when a composition's two states share a colour: every tween matches the dim baseline, and the + * active override is silently dropped. A declaration is the composition telling us what it built, + * rather than us inferring it from what it happens to look like. + */ +function declaredCaptionState(tween: GsapTween): "dim" | "active" | undefined { + const data = tween.vars.data as { captionState?: unknown } | undefined; + const state = data?.captionState; + return state === "dim" || state === "active" ? state : undefined; +} + function resolveCaptionWordElement(el: Element | null): HTMLElement | null { if (!(el instanceof HTMLElement)) return null; if (el.dataset.captionWrapper !== "true") return el; @@ -121,30 +138,37 @@ 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) { - const tweenColor = String(tw.vars.color); - if (tweenColor === dimBaseline) { - // This tween targets the dim/inactive color + // A declaration wins over the colour guess, per tween, so a composition can declare + // some tweens and leave others to the fallback. + const state = + declaredCaptionState(tw) ?? + (String(tw.vars.color) === dimBaseline ? "dim" : "active"); + if (state === "dim") { if (override.dimColor) tw.vars.color = override.dimColor; - } else { - // This tween targets the active/spoken color - if (override.activeColor) tw.vars.color = override.activeColor; + } else if (override.activeColor) { + tw.vars.color = override.activeColor; } }