mirror of
https://github.com/calesthio/OpenMontage.git
synced 2026-08-25 09:34:21 +08:00
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:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
130
tests/contracts/test_theme_text_contrast_contract.py
Normal file
130
tests/contracts/test_theme_text_contrast_contract.py
Normal 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"
|
||||
)
|
||||
Reference in New Issue
Block a user