From a13315b1e730ee4d0f26ed6b7ad5ac672544ff40 Mon Sep 17 00:00:00 2001 From: "bbudaedu@gmail.com" Date: Mon, 3 Aug 2026 21:22:55 +0800 Subject: [PATCH] fix(remotion): keep themed text legible on light playbooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three of the five shipped playbooks are light-background — clean-professional, minimalist-diagram, premium-minimalist — but several components render near-white text unconditionally. Worst case is the burned-in captions. Explainer passed CaptionOverlay the theme's `captionHighlightColor` and `captionBackgroundColor` but never its `color`, so the word color stayed at CaptionOverlay's dark-theme default: caption text #F8FAFC on the light caption bar -> 1.05:1 (WCAG AA for normal text is 4.5:1) Captions are an accessibility feature; on the majority of playbooks they were invisible. HeroTitle was worse — it took no color props at all and hardcoded #22D3EE / #F8FAFC / #A78BFA plus a dark scrim. SectionTitle and StatReveal hardcoded #F8FAFC for their secondary text. - Explainer passes `color={theme.textColor}` to CaptionOverlay. - OverlayRenderer now receives the theme; it previously had no access to one, so no overlay could follow the theme even in principle. - SectionTitle, StatReveal and HeroTitle take a `textColor` prop; HeroTitle also takes accentColor/subtitleColor/scrimBackground. - Every new prop defaults to the exact value it replaced, so callers that do not thread a theme are unchanged — TalkingHead.tsx resolves no theme and renders identically. The scrim needed to flip too. HeroTitle's dark radial wash under a light theme's dark title composites to ~#7B808A, putting #1F2937 text at ~3.4:1 — the same legibility bug in reverse. `heroScrim()` derives the wash from `isLightColor(theme.backgroundColor)`, reusing the helper already in Explainer.tsx, and reproduces the previous gradient exactly for dark themes. Coverage follows the source-text idiom of test_remotion_video_transition_contract.py for the wiring, and adds a behavioral check on top: for every playbook, the theme `_build_theme_from_playbook` derives must clear WCAG AA with its own caption bar, compositing the translucent bar over the background the way the GPU would. Current margins are 14.7-17.6:1. Verified: 12 of the 16 new tests fail on the unfixed tree; full suite goes 964 -> 980 passed with no regressions; `tsc --noEmit` clean under strict. --- remotion-composer/src/Explainer.tsx | 47 ++++++- .../src/components/HeroTitle.tsx | 33 ++++- .../src/components/SectionTitle.tsx | 5 +- .../src/components/StatReveal.tsx | 5 +- .../test_theme_text_contrast_contract.py | 130 ++++++++++++++++++ 5 files changed, 206 insertions(+), 14 deletions(-) create mode 100644 tests/contracts/test_theme_text_contrast_contract.py diff --git a/remotion-composer/src/Explainer.tsx b/remotion-composer/src/Explainer.tsx index 91b0da67..7b3d3e76 100644 --- a/remotion-composer/src/Explainer.tsx +++ b/remotion-composer/src/Explainer.tsx @@ -59,6 +59,19 @@ function isLightColor(hex: string): boolean { return (r * 299 + g * 587 + b * 114) / 1000 > 128; } +// Scrim painted behind a hero title. It has to wash *away* from the theme's +// text color: a dark scrim under a light theme's dark text drops the pair to +// ~3.4:1, which is the same legibility bug in reverse. +function heroScrim(theme: ThemeConfig): string { + const { r, g, b } = hexToRgb( + isLightColor(theme.backgroundColor) ? "#FFFFFF" : "#0F172A" + ); + return ( + `radial-gradient(ellipse at center, rgba(${r},${g},${b},0.35) 0%, ` + + `rgba(${r},${g},${b},0.55) 100%)` + ); +} + // Darken/lighten a color by mixing toward black or white function shiftColor(hex: string, amount: number): string { const { r, g, b } = hexToRgb(hex); @@ -607,7 +620,14 @@ const SceneRenderer: React.FC<{ cut: Cut; theme: ThemeConfig }> = ({ cut, theme } if (cut.type === "hero_title" && cut.text) { return maybeWrapWithBg( - + ); } if (cut.type === "terminal_scene" && cut.steps) { @@ -752,13 +772,17 @@ const SceneRenderer: React.FC<{ cut: Cut; theme: ThemeConfig }> = ({ cut, theme // Overlay renderer // --------------------------------------------------------------------------- -const OverlayRenderer: React.FC<{ overlay: Overlay }> = ({ overlay }) => { +const OverlayRenderer: React.FC<{ overlay: Overlay; theme: ThemeConfig }> = ({ + overlay, + theme, +}) => { if (overlay.type === "section_title") { return ( ); @@ -768,13 +792,23 @@ const OverlayRenderer: React.FC<{ overlay: Overlay }> = ({ overlay }) => { ); } if (overlay.type === "hero_title") { - return ; + return ( + + ); } if (overlay.type === "provider_chip" && overlay.providers) { return ( @@ -827,7 +861,7 @@ export const Explainer: React.FC = (props) => { return ( - + ); })} @@ -838,6 +872,7 @@ export const Explainer: React.FC = (props) => { words={captions} wordsPerPage={6} fontSize={42} + color={theme.textColor} highlightColor={theme.captionHighlightColor} backgroundColor={theme.captionBackgroundColor} /> diff --git a/remotion-composer/src/components/HeroTitle.tsx b/remotion-composer/src/components/HeroTitle.tsx index d4b6321d..e22e4bdb 100644 --- a/remotion-composer/src/components/HeroTitle.tsx +++ b/remotion-composer/src/components/HeroTitle.tsx @@ -9,9 +9,31 @@ import { type HeroTitleProps = { title: string; subtitle?: string; + /** Color of the leading accent characters and the underline. */ + accentColor?: string; + /** Color of the remaining title characters. Pass the theme's textColor. */ + textColor?: string; + /** Subtitle color. */ + subtitleColor?: string; + /** + * Scrim painted behind the title so it separates from whatever is underneath. + * Defaults to a dark wash; a light theme must pass a light one, otherwise the + * scrim darkens the backdrop and cancels out the theme's dark text. + */ + scrimBackground?: string; }; -export const HeroTitle: React.FC = ({ title, subtitle }) => { +const DEFAULT_SCRIM = + "radial-gradient(ellipse at center, rgba(15,23,42,0.35) 0%, rgba(15,23,42,0.55) 100%)"; + +export const HeroTitle: React.FC = ({ + title, + subtitle, + accentColor = "#22D3EE", + textColor = "#F8FAFC", + subtitleColor = "#A78BFA", + scrimBackground = DEFAULT_SCRIM, +}) => { const frame = useCurrentFrame(); const { fps } = useVideoConfig(); @@ -23,8 +45,7 @@ export const HeroTitle: React.FC = ({ title, subtitle }) => { style={{ justifyContent: "center", alignItems: "center", - background: - "radial-gradient(ellipse at center, rgba(15,23,42,0.35) 0%, rgba(15,23,42,0.55) 100%)", + background: scrimBackground, }} >
@@ -56,7 +77,7 @@ export const HeroTitle: React.FC = ({ title, subtitle }) => { display: "inline-block", opacity: charSpring, transform: `translateY(${interpolate(charSpring, [0, 1], [30, 0])}px)`, - color: i < 8 ? "#22D3EE" : "#F8FAFC", // Accent first word + color: i < 8 ? accentColor : textColor, // Accent first word whiteSpace: char === " " ? "pre" : undefined, minWidth: char === " " ? "0.3em" : undefined, }} @@ -79,7 +100,7 @@ export const HeroTitle: React.FC = ({ title, subtitle }) => { }), fontSize: 28, fontWeight: 400, - color: "#A78BFA", + color: subtitleColor, fontFamily: "Space Grotesk, Inter, system-ui, sans-serif", letterSpacing: "0.1em", textTransform: "uppercase", @@ -94,7 +115,7 @@ export const HeroTitle: React.FC = ({ title, subtitle }) => { style={{ margin: "24px auto 0", height: 3, - backgroundColor: "#22D3EE", + backgroundColor: accentColor, borderRadius: 2, width: interpolate( spring({ diff --git a/remotion-composer/src/components/SectionTitle.tsx b/remotion-composer/src/components/SectionTitle.tsx index 186d46e0..c244f2cc 100644 --- a/remotion-composer/src/components/SectionTitle.tsx +++ b/remotion-composer/src/components/SectionTitle.tsx @@ -10,6 +10,8 @@ interface SectionTitleProps { title: string; subtitle?: string; accentColor?: string; + /** Title color. Defaults to near-white; pass the theme's textColor on light themes. */ + textColor?: string; position?: "top-left" | "bottom-left" | "center"; } @@ -17,6 +19,7 @@ export const SectionTitle: React.FC = ({ title, subtitle, accentColor = "#22D3EE", + textColor = "#F8FAFC", position = "top-left", }) => { const frame = useCurrentFrame(); @@ -67,7 +70,7 @@ export const SectionTitle: React.FC = ({ style={{ fontSize: 28, fontWeight: 700, - color: "#F8FAFC", + color: textColor, fontFamily: "Space Grotesk, Inter, system-ui, sans-serif", letterSpacing: "0.05em", textTransform: "uppercase", diff --git a/remotion-composer/src/components/StatReveal.tsx b/remotion-composer/src/components/StatReveal.tsx index f9846a25..a1b7b27e 100644 --- a/remotion-composer/src/components/StatReveal.tsx +++ b/remotion-composer/src/components/StatReveal.tsx @@ -10,6 +10,8 @@ interface StatRevealProps { stat: string; label?: string; accentColor?: string; + /** Label color. Defaults to near-white; pass the theme's textColor on light themes. */ + textColor?: string; position?: "center" | "bottom-right" | "right"; } @@ -17,6 +19,7 @@ export const StatReveal: React.FC = ({ stat, label, accentColor = "#A78BFA", + textColor = "#F8FAFC", position = "bottom-right", }) => { const frame = useCurrentFrame(); @@ -79,7 +82,7 @@ export const StatReveal: React.FC = ({ style={{ fontSize: 22, fontWeight: 500, - color: "#F8FAFC", + color: textColor, fontFamily: "Space Grotesk, Inter, system-ui, sans-serif", marginTop: 8, opacity: spring({ diff --git a/tests/contracts/test_theme_text_contrast_contract.py b/tests/contracts/test_theme_text_contrast_contract.py new file mode 100644 index 00000000..fb28f619 --- /dev/null +++ b/tests/contracts/test_theme_text_contrast_contract.py @@ -0,0 +1,130 @@ +"""Theme-driven text must stay legible on light playbooks. + +Three of the five shipped playbooks are light-background (clean-professional, +minimalist-diagram, premium-minimalist), but several Remotion components render +near-white text unconditionally. The worst case is the burned-in captions: +Explainer handed CaptionOverlay a light `captionBackgroundColor` while leaving +the word color at its `#F8FAFC` default, which is 1.05:1 — invisible. + +The wiring assertions follow the source-text idiom already used by +test_remotion_video_transition_contract.py; the contrast assertion checks the +values that wiring actually delivers. +""" + +import re +from pathlib import Path + +import pytest + +from styles.playbook_loader import list_playbooks, validate_contrast +from tools.video.video_compose import VideoCompose + +REPO_ROOT = Path(__file__).resolve().parents[2] +COMPOSER = REPO_ROOT / "remotion-composer" / "src" + +# WCAG 2.1 AA for normal-size text. +MIN_CONTRAST = 4.5 + + +def _read(relative: str) -> str: + return (COMPOSER / relative).read_text(encoding="utf-8") + + +def _to_rgba(color: str) -> tuple[float, float, float, float]: + color = color.strip() + if color.startswith("#"): + hex_digits = color[1:] + if len(hex_digits) == 3: + hex_digits = "".join(c * 2 for c in hex_digits) + r, g, b = (int(hex_digits[i : i + 2], 16) for i in (0, 2, 4)) + return (r, g, b, 1.0) + match = re.match(r"rgba?\(([^)]+)\)", color) + if not match: + raise ValueError(f"unparseable color: {color!r}") + parts = [float(p) for p in match.group(1).split(",")] + alpha = parts[3] if len(parts) > 3 else 1.0 + return (parts[0], parts[1], parts[2], alpha) + + +def _composite(foreground: str, backdrop: str) -> str: + """Flatten a possibly-translucent color over an opaque one, as the GPU would.""" + fr, fg, fb, alpha = _to_rgba(foreground) + br, bg, bb, _ = _to_rgba(backdrop) + blended = ( + round(alpha * fr + (1 - alpha) * br), + round(alpha * fg + (1 - alpha) * bg), + round(alpha * fb + (1 - alpha) * bb), + ) + return "#%02X%02X%02X" % blended + + +def test_explainer_gives_captions_the_theme_text_color() -> None: + """Regression: the caption word color was left at CaptionOverlay's dark-theme default.""" + source = _read("Explainer.tsx") + + caption_call = source[source.index("")] + + assert "color={theme.textColor}" in caption_call + assert "highlightColor={theme.captionHighlightColor}" in caption_call + assert "backgroundColor={theme.captionBackgroundColor}" in caption_call + + +def test_overlay_renderer_receives_the_theme() -> None: + """OverlayRenderer had no access to the theme at all, so overlays could not follow it.""" + source = _read("Explainer.tsx") + + assert "OverlayRenderer: React.FC<{ overlay: Overlay; theme: ThemeConfig }>" in source + assert "" in source + + +@pytest.mark.parametrize("component", ["SectionTitle", "StatReveal", "HeroTitle"]) +def test_theme_text_color_is_threaded_into_overlay_components(component: str) -> None: + source = _read("Explainer.tsx") + + call = source[source.index(f"<{component}") :] + call = call[: call.index("/>")] + assert "textColor=" in call, f"<{component}> is not given a text color" + + +@pytest.mark.parametrize( + ("component", "expected"), + [ + ("components/SectionTitle.tsx", "color: textColor,"), + ("components/StatReveal.tsx", "color: textColor,"), + ("components/HeroTitle.tsx", "color: i < 8 ? accentColor : textColor,"), + ("components/HeroTitle.tsx", "color: subtitleColor,"), + ("components/HeroTitle.tsx", "backgroundColor: accentColor,"), + ("components/HeroTitle.tsx", "background: scrimBackground,"), + ], +) +def test_components_render_from_props_not_literals(component: str, expected: str) -> None: + """The palette may survive as default parameter values, but not inline in the JSX.""" + assert expected in _read(component) + + +def test_hero_scrim_flips_with_theme_lightness() -> None: + """A dark scrim under a light theme's dark title drops the pair to ~3.4:1.""" + source = _read("Explainer.tsx") + + scrim = source[source.index("function heroScrim") :] + scrim = scrim[: scrim.index("\n}")] + assert "isLightColor(theme.backgroundColor)" in scrim + assert '"#FFFFFF"' in scrim + assert '"#0F172A"' in scrim + + +@pytest.mark.parametrize("playbook", sorted(list_playbooks())) +def test_every_playbook_theme_keeps_captions_legible(playbook: str) -> None: + """The color the wiring delivers must actually pass AA against the caption bar.""" + theme = VideoCompose()._build_theme_from_playbook(playbook, {}) + if not theme: + pytest.skip(f"{playbook} does not currently yield a theme") + + caption_bar = _composite(theme["captionBackgroundColor"], theme["backgroundColor"]) + ratio = validate_contrast(theme["textColor"], caption_bar)["ratio"] + + assert ratio >= MIN_CONTRAST, ( + f"{playbook}: caption text {theme['textColor']} on bar {caption_bar} " + f"is {ratio}:1, below WCAG AA {MIN_CONTRAST}:1" + )