fix(remotion): keep themed text legible on light playbooks

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.
This commit is contained in:
bbudaedu@gmail.com
2026-08-03 21:22:55 +08:00
parent 4eab34c5cf
commit a13315b1e7
5 changed files with 206 additions and 14 deletions

View File

@@ -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(
<HeroTitle title={cut.text} subtitle={cut.heroSubtitle || cut.subtitle} />
<HeroTitle
title={cut.text}
subtitle={cut.heroSubtitle || cut.subtitle}
accentColor={accent}
textColor={textColor}
subtitleColor={theme.mutedTextColor}
scrimBackground={heroScrim(theme)}
/>
);
}
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 (
<SectionTitle
title={overlay.text ?? ""}
subtitle={overlay.subtitle}
accentColor={overlay.accentColor}
accentColor={overlay.accentColor || theme.accentColor}
textColor={theme.textColor}
position={(overlay.position as any) || "top-left"}
/>
);
@@ -768,13 +792,23 @@ const OverlayRenderer: React.FC<{ overlay: Overlay }> = ({ overlay }) => {
<StatReveal
stat={overlay.text ?? ""}
label={overlay.subtitle}
accentColor={overlay.accentColor}
accentColor={overlay.accentColor || theme.accentColor}
textColor={theme.textColor}
position={(overlay.position as any) || "bottom-right"}
/>
);
}
if (overlay.type === "hero_title") {
return <HeroTitle title={overlay.text ?? ""} subtitle={overlay.subtitle} />;
return (
<HeroTitle
title={overlay.text ?? ""}
subtitle={overlay.subtitle}
accentColor={overlay.accentColor || theme.accentColor}
textColor={theme.textColor}
subtitleColor={theme.mutedTextColor}
scrimBackground={heroScrim(theme)}
/>
);
}
if (overlay.type === "provider_chip" && overlay.providers) {
return (
@@ -827,7 +861,7 @@ export const Explainer: React.FC<ExplainerProps> = (props) => {
return (
<Sequence key={`overlay-${i}`} from={from} durationInFrames={duration}>
<OverlayRenderer overlay={overlay} />
<OverlayRenderer overlay={overlay} theme={theme} />
</Sequence>
);
})}
@@ -838,6 +872,7 @@ export const Explainer: React.FC<ExplainerProps> = (props) => {
words={captions}
wordsPerPage={6}
fontSize={42}
color={theme.textColor}
highlightColor={theme.captionHighlightColor}
backgroundColor={theme.captionBackgroundColor}
/>

View File

@@ -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<HeroTitleProps> = ({ 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<HeroTitleProps> = ({
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<HeroTitleProps> = ({ 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,
}}
>
<div style={{ textAlign: "center", maxWidth: "85%" }}>
@@ -56,7 +77,7 @@ export const HeroTitle: React.FC<HeroTitleProps> = ({ 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<HeroTitleProps> = ({ 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<HeroTitleProps> = ({ title, subtitle }) => {
style={{
margin: "24px auto 0",
height: 3,
backgroundColor: "#22D3EE",
backgroundColor: accentColor,
borderRadius: 2,
width: interpolate(
spring({

View File

@@ -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<SectionTitleProps> = ({
title,
subtitle,
accentColor = "#22D3EE",
textColor = "#F8FAFC",
position = "top-left",
}) => {
const frame = useCurrentFrame();
@@ -67,7 +70,7 @@ export const SectionTitle: React.FC<SectionTitleProps> = ({
style={{
fontSize: 28,
fontWeight: 700,
color: "#F8FAFC",
color: textColor,
fontFamily: "Space Grotesk, Inter, system-ui, sans-serif",
letterSpacing: "0.05em",
textTransform: "uppercase",

View File

@@ -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<StatRevealProps> = ({
stat,
label,
accentColor = "#A78BFA",
textColor = "#F8FAFC",
position = "bottom-right",
}) => {
const frame = useCurrentFrame();
@@ -79,7 +82,7 @@ export const StatReveal: React.FC<StatRevealProps> = ({
style={{
fontSize: 22,
fontWeight: 500,
color: "#F8FAFC",
color: textColor,
fontFamily: "Space Grotesk, Inter, system-ui, sans-serif",
marginTop: 8,
opacity: spring({

View File

@@ -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("<CaptionOverlay") :]
caption_call = caption_call[: caption_call.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 "<OverlayRenderer overlay={overlay} theme={theme} />" 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"
)