diff --git a/packages/core/src/runtime/captionOverrides.test.ts b/packages/core/src/runtime/captionOverrides.test.ts index 15ff8c050..03d981d50 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,52 @@ 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"); + }); +}); diff --git a/packages/core/src/runtime/captionOverrides.ts b/packages/core/src/runtime/captionOverrides.ts index e89e557a8..ac3640fe3 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; @@ -138,13 +155,15 @@ export function applyCaptionOverrides(): void { const dimBaseline = colorTweens[0] ? String(colorTweens[0].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; } }