Initial release — OpenMontage: the first open-source agentic video production system

11 production pipelines, 47 tools, 124 agent skills.
Supports cloud APIs (fal.ai, OpenAI, ElevenLabs, Suno, HeyGen, Runway) and
free local providers (diffusers, Piper TTS, WAN 2.1, Hunyuan, CogVideo).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
calesthio
2026-03-29 08:25:17 -07:00
commit a3e735cc7a
1147 changed files with 240221 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
identity:
name: "Clean Professional"
category: motion-graphics
mood: polished, trustworthy, clear
pace: moderate
best_for: "Corporate explainers, educational content, SaaS demos, conference talks"
visual_language:
color_palette:
primary: ["#2563EB", "#1E40AF"]
accent: ["#F59E0B", "#10B981"]
background: "#FFFFFF"
text: "#1F2937"
muted: "#6B7280"
composition: centered, generous whitespace, rule-of-thirds for key visuals
texture: clean flat, no grain, no noise
typography:
headings:
font: "Inter"
weight: 700
tracking: "-0.02em"
body:
font: "Inter"
weight: 400
line_height: 1.6
code:
font: "JetBrains Mono"
weight: 400
stat_card:
font: "Inter"
weight: 800
size_multiplier: 3.0
scale_system: "major_third"
weight_matrix:
title: 800
heading: 700
body: 400
caption: 400
motion:
transitions: [fade, dissolve, slide-left]
animation_style: "ease-in-out, clean, no bounce"
pacing_rules:
min_scene_hold_seconds: 2.5
max_scene_hold_seconds: 12
text_card_hold_seconds: 3.5
stat_card_hold_seconds: 3.0
transition_duration_seconds: 0.4
entrance: "fade-up with slight scale (0.95 -> 1.0)"
exit: "fade-out"
audio:
voice_style: "professional, clear, moderate pace, warm but authoritative"
music_mood: "ambient corporate, upbeat but not distracting"
music_volume: 0.08
sfx_style: "subtle clicks, soft whooshes on transitions"
ducking_threshold_db: -3
asset_generation:
image_prompt_prefix: "clean professional flat illustration, corporate style, white background, "
image_negative_prompt: "photorealistic, 3d render, dark, grungy, cluttered, low quality"
diagram_style: "clean lines, rounded nodes, subtle shadows"
consistency_anchors:
- "Blue (#2563EB) as primary accent in all illustrations"
- "White/light gray backgrounds throughout"
- "Flat illustration style, no 3D or photorealism"
- "Consistent lighting: soft, even, no dramatic shadows"
overlays:
stat_card:
bg: "#F3F4F6"
border: "#2563EB"
radius: 8
shadow: "0 2px 8px rgba(0,0,0,0.08)"
key_term:
bg: "#EFF6FF"
text: "#1E40AF"
radius: 4
code_block:
bg: "#1F2937"
text: "#E5E7EB"
highlight: "#F59E0B"
quality_rules:
- "Minimum contrast ratio 4.5:1 for all text"
- "No more than 3 colors on screen at once (excluding background)"
- "Text must be readable at 720p"
- "Every generated image uses the blue-white-gray palette"
- "Hold establishing shots for 2.5s minimum"
- "No rapid cuts — minimum 2s between transitions"
chart_palette:
- "#2563EB"
- "#F59E0B"
- "#10B981"
- "#8B5CF6"
- "#EF4444"
- "#06B6D4"
color_rules:
harmony_type: "analogous"
contrast_validation: true
colorblind_safe: true
+104
View File
@@ -0,0 +1,104 @@
identity:
name: "Flat Motion Graphics"
category: motion-graphics
mood: energetic, modern, bold
pace: fast
best_for: "Social media explainers, TikTok/Reels, product launches, startup pitches"
visual_language:
color_palette:
primary: ["#7C3AED", "#4F46E5"]
accent: ["#EC4899", "#06B6D4"]
background: "#0F172A"
text: "#F8FAFC"
muted: "#64748B"
composition: edge-to-edge, bold framing, off-center for dynamism
texture: flat color blocks, geometric shapes, no gradients except on backgrounds
typography:
headings:
font: "Space Grotesk"
weight: 700
tracking: "-0.03em"
body:
font: "Space Grotesk"
weight: 400
line_height: 1.5
code:
font: "Fira Code"
weight: 400
stat_card:
font: "Space Grotesk"
weight: 800
size_multiplier: 4.0
scale_system: "perfect_fourth"
weight_matrix:
title: 800
heading: 700
body: 400
caption: 300
motion:
transitions: [wipe-left, zoom-in, morph, cut]
animation_style: "spring(1, 80, 10), bouncy, overshooting"
pacing_rules:
min_scene_hold_seconds: 1.5
max_scene_hold_seconds: 8
text_card_hold_seconds: 2.5
stat_card_hold_seconds: 2.0
transition_duration_seconds: 0.3
entrance: "scale-up with bounce (0.8 -> 1.05 -> 1.0)"
exit: "scale-down-fade"
audio:
voice_style: "energetic, conversational, slightly fast pace, Gen-Z friendly"
music_mood: "lo-fi beat, electronic, driving rhythm"
music_volume: 0.12
sfx_style: "punchy pops, whooshes, glitch sounds on key moments"
ducking_threshold_db: -4
asset_generation:
image_prompt_prefix: "flat vector illustration, bold colors, dark background, geometric shapes, "
image_negative_prompt: "photorealistic, watercolor, vintage, muted colors, white background"
diagram_style: "neon outlines on dark background, rounded rectangles, glowing connections"
consistency_anchors:
- "Purple (#7C3AED) as primary accent throughout"
- "Dark slate (#0F172A) backgrounds in all scenes"
- "Bold, geometric illustration style"
- "Neon/bright accent pops for emphasis"
overlays:
stat_card:
bg: "#1E293B"
border: "#7C3AED"
radius: 12
shadow: "0 0 20px rgba(124,58,237,0.3)"
key_term:
bg: "#1C204F"
text: "#C4B5FD"
radius: 6
code_block:
bg: "#0F172A"
text: "#E2E8F0"
highlight: "#EC4899"
quality_rules:
- "Minimum contrast ratio 4.5:1 for all text"
- "Maximum 4 colors on screen (dark bg counts as one)"
- "Text must be readable at 720p on mobile (larger font sizes)"
- "Every scene has at least one animated element"
- "Pacing: new visual element every 2-3 seconds"
- "Sound effect on every major transition"
chart_palette:
- "#7C3AED"
- "#EC4899"
- "#06B6D4"
- "#F59E0B"
- "#10B981"
- "#EF4444"
color_rules:
harmony_type: "split-complementary"
contrast_validation: true
colorblind_safe: true
+103
View File
@@ -0,0 +1,103 @@
identity:
name: "Minimalist Diagram"
category: whiteboard
mood: focused, technical, precise
pace: deliberate
best_for: "Technical deep-dives, architecture explainers, math/CS concepts, documentation videos"
visual_language:
color_palette:
primary: ["#1A1A2E", "#16213E"]
accent: ["#E94560", "#0F3460"]
background: "#FAFAFA"
text: "#1A1A2E"
muted: "#6B7280"
composition: centered with generous margins, whiteboard feel, grid-aligned
texture: paper-like subtle texture, hand-drawn line quality optional
typography:
headings:
font: "IBM Plex Sans"
weight: 600
tracking: "0"
body:
font: "IBM Plex Sans"
weight: 400
line_height: 1.7
code:
font: "IBM Plex Mono"
weight: 400
stat_card:
font: "IBM Plex Sans"
weight: 700
size_multiplier: 2.5
scale_system: "minor_third"
weight_matrix:
title: 700
heading: 600
body: 400
caption: 300
motion:
transitions: [fade, dissolve]
animation_style: "ease-out, deliberate, no bounce, no overshoot"
pacing_rules:
min_scene_hold_seconds: 3.0
max_scene_hold_seconds: 15
text_card_hold_seconds: 4.0
stat_card_hold_seconds: 3.5
transition_duration_seconds: 0.5
entrance: "draw-in (SVG path animation) or fade-up"
exit: "fade-out"
audio:
voice_style: "calm, measured, technical but accessible, professor-like"
music_mood: "minimal ambient, piano or synth pad, barely noticeable"
music_volume: 0.05
sfx_style: "soft pen strokes on diagram reveals, subtle click on node connections"
ducking_threshold_db: -2
asset_generation:
image_prompt_prefix: "minimalist technical diagram, clean lines, light background, blueprint style, "
image_negative_prompt: "colorful, playful, 3d, photorealistic, busy, cluttered"
diagram_style: "thin dark lines on light background, square nodes, minimal color, monochrome with one accent"
consistency_anchors:
- "Red (#E94560) as the ONLY accent color for emphasis"
- "Light gray (#FAFAFA) backgrounds throughout"
- "Monochrome diagrams with single accent highlight"
- "Consistent line weight across all diagrams"
overlays:
stat_card:
bg: "#FFFFFF"
border: "#1A1A2E"
radius: 2
shadow: "none"
key_term:
bg: "#FEE2E2"
text: "#B91C3C"
radius: 2
code_block:
bg: "#1A1A2E"
text: "#D1D5DB"
highlight: "#E94560"
quality_rules:
- "Minimum contrast ratio 7:1 for body text (WCAG AAA)"
- "Maximum 2 colors on screen (base + accent)"
- "Diagrams must be legible at 480p (no fine detail)"
- "Progressive reveal for all diagrams — never show full diagram at once"
- "Hold each diagram step for 3s minimum"
- "Every concept gets its own visual — no cramming"
- "Whitespace is content — at least 30% of frame should be empty"
chart_palette:
- "#E94560"
- "#1A1A2E"
- "#0F3460"
- "#6B7280"
color_rules:
harmony_type: "complementary"
contrast_validation: true
colorblind_safe: true
+828
View File
@@ -0,0 +1,828 @@
"""Style playbook loader.
Loads, validates, and lists style playbook YAML files from styles/.
Includes design intelligence: color palette analysis, typography intelligence,
and accessibility validation (D3.5.5, D3.5.6, D3.5.7).
"""
from __future__ import annotations
import colorsys
import json
import math
from pathlib import Path
from typing import Any, Optional
import yaml
import jsonschema
STYLES_DIR = Path(__file__).resolve().parent
SCHEMA_PATH = (
Path(__file__).resolve().parent.parent
/ "schemas"
/ "styles"
/ "playbook.schema.json"
)
def _load_playbook_schema() -> dict:
with open(SCHEMA_PATH) as f:
return json.load(f)
def load_playbook(name: str, styles_dir: Optional[Path] = None) -> dict[str, Any]:
"""Load and validate a style playbook by name.
Args:
name: Playbook name (without .yaml extension).
styles_dir: Override directory for playbook files.
Returns:
Validated playbook dict.
"""
styles_dir = styles_dir or STYLES_DIR
path = styles_dir / f"{name}.yaml"
if not path.exists():
raise FileNotFoundError(f"Playbook not found: {path}")
with open(path) as f:
playbook = yaml.safe_load(f)
validate_playbook(playbook)
return playbook
def validate_playbook(playbook: dict) -> None:
"""Validate a playbook dict against the schema."""
schema = _load_playbook_schema()
jsonschema.validate(instance=playbook, schema=schema)
def list_playbooks(styles_dir: Optional[Path] = None) -> list[str]:
"""List all available playbook names."""
styles_dir = styles_dir or STYLES_DIR
return [
p.stem
for p in styles_dir.glob("*.yaml")
if p.stem != "__pycache__"
]
# ---------------------------------------------------------------------------
# Color math helpers (pure Python, no external deps)
# ---------------------------------------------------------------------------
def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
"""Convert hex color string to (R, G, B) tuple (0-255)."""
h = hex_color.lstrip("#")
if len(h) == 3:
h = h[0] * 2 + h[1] * 2 + h[2] * 2
# Handle 8-char hex (with alpha) by taking first 6 chars
if len(h) == 8:
h = h[:6]
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
def _has_alpha(hex_color: str) -> bool:
"""Check if a hex color string includes an alpha channel (8-char hex)."""
return len(hex_color.lstrip("#")) == 8
def _composite_alpha(fg_hex: str, bg_hex: str) -> str:
"""Composite an RGBA foreground color onto an opaque background.
Args:
fg_hex: Foreground color as 8-char hex (#RRGGBBAA).
bg_hex: Opaque background color as 6-char hex (#RRGGBB).
Returns:
Composited opaque hex color string.
"""
h = fg_hex.lstrip("#")
alpha = int(h[6:8], 16) / 255.0
fg_r, fg_g, fg_b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
bg_r, bg_g, bg_b = _hex_to_rgb(bg_hex)
r = round(alpha * fg_r + (1 - alpha) * bg_r)
g = round(alpha * fg_g + (1 - alpha) * bg_g)
b = round(alpha * fg_b + (1 - alpha) * bg_b)
return _rgb_to_hex(r, g, b)
def _rgb_to_hex(r: int, g: int, b: int) -> str:
"""Convert (R, G, B) tuple to hex string."""
r = max(0, min(255, round(r)))
g = max(0, min(255, round(g)))
b = max(0, min(255, round(b)))
return f"#{r:02X}{g:02X}{b:02X}"
def _srgb_linearize(c: float) -> float:
"""Convert sRGB channel (0-1) to linear RGB."""
if c <= 0.04045:
return c / 12.92
return ((c + 0.055) / 1.055) ** 2.4
def _relative_luminance(hex_color: str) -> float:
"""Calculate sRGB relative luminance per WCAG 2.1.
L = 0.2126*R + 0.7152*G + 0.0722*B
where R, G, B are linearized sRGB values.
"""
r, g, b = _hex_to_rgb(hex_color)
r_lin = _srgb_linearize(r / 255.0)
g_lin = _srgb_linearize(g / 255.0)
b_lin = _srgb_linearize(b / 255.0)
return 0.2126 * r_lin + 0.7152 * g_lin + 0.0722 * b_lin
def _hex_to_hsl(hex_color: str) -> tuple[float, float, float]:
"""Convert hex color to HSL (h: 0-360, s: 0-1, l: 0-1)."""
r, g, b = _hex_to_rgb(hex_color)
h, l, s = colorsys.rgb_to_hls(r / 255.0, g / 255.0, b / 255.0)
return h * 360.0, s, l
def _hsl_to_hex(h: float, s: float, l: float) -> str:
"""Convert HSL (h: 0-360, s: 0-1, l: 0-1) to hex color."""
h_norm = (h % 360) / 360.0
r, g, b = colorsys.hls_to_rgb(h_norm, l, s)
return _rgb_to_hex(round(r * 255), round(g * 255), round(b * 255))
# ---------------------------------------------------------------------------
# Color-blind simulation matrices
# ---------------------------------------------------------------------------
# Confusion pair thresholds for each type of color vision deficiency.
# These simulate which hue ranges become indistinguishable.
_CVD_CONFUSION_PAIRS: dict[str, list[tuple[tuple[int, int], tuple[int, int]]]] = {
# Deuteranopia: red-green confusion (most common, ~6% of males)
"deuteranopia": [
((0, 30), (90, 150)), # Red vs green
((30, 60), (90, 130)), # Orange vs green
((330, 360), (90, 150)), # Magenta-red vs green
],
# Protanopia: red-green confusion (shifted, ~1% of males)
"protanopia": [
((0, 40), (80, 140)), # Red-orange vs green
((340, 360), (80, 140)), # Red vs green
((0, 20), (170, 200)), # Red vs cyan
],
# Tritanopia: blue-yellow confusion (~0.01%)
"tritanopia": [
((200, 270), (50, 100)), # Blue vs yellow-green
((220, 260), (40, 80)), # Blue vs yellow
((170, 210), (300, 340)), # Cyan vs pink
],
}
def _hue_in_range(hue: float, hue_range: tuple[int, int]) -> bool:
"""Check if a hue falls within a range (handles wrap-around)."""
low, high = hue_range
if low <= high:
return low <= hue <= high
# Wrap-around (e.g., 330-30 means 330-360 and 0-30)
return hue >= low or hue <= high
# ---------------------------------------------------------------------------
# D3.5.5 — Color palette intelligence
# ---------------------------------------------------------------------------
def validate_contrast(fg_hex: str, bg_hex: str) -> dict:
"""Calculate WCAG 2.1 contrast ratio between foreground and background.
Uses sRGB relative luminance: L = 0.2126*R + 0.7152*G + 0.0722*B
Args:
fg_hex: Foreground color as hex string (e.g., "#1F2937").
bg_hex: Background color as hex string (e.g., "#FFFFFF").
Returns:
Dict with ratio, AA/AAA pass/fail for normal and large text.
"""
l1 = _relative_luminance(fg_hex)
l2 = _relative_luminance(bg_hex)
lighter = max(l1, l2)
darker = min(l1, l2)
ratio = (lighter + 0.05) / (darker + 0.05)
return {
"foreground": fg_hex,
"background": bg_hex,
"ratio": round(ratio, 2),
"normal_text": {
"AA": ratio >= 4.5,
"AAA": ratio >= 7.0,
},
"large_text": {
"AA": ratio >= 3.0,
"AAA": ratio >= 4.5,
},
}
def check_color_blind_safety(colors: list[str]) -> dict:
"""Check a list of colors for color-blind confusion pairs.
Simulates deuteranopia, protanopia, and tritanopia confusion.
Flags color pairs that may become indistinguishable.
Args:
colors: List of hex color strings.
Returns:
Dict with per-deficiency results and flagged pairs.
"""
hues = []
for c in colors:
h, s, l = _hex_to_hsl(c)
hues.append({"hex": c, "hue": h, "saturation": s, "lightness": l})
results: dict[str, Any] = {
"safe": True,
"colors_analyzed": len(colors),
"issues": [],
}
for cvd_type, confusion_ranges in _CVD_CONFUSION_PAIRS.items():
for i in range(len(hues)):
for j in range(i + 1, len(hues)):
c1 = hues[i]
c2 = hues[j]
# Skip very desaturated colors (grays) — distinguishable by
# lightness alone regardless of hue perception.
if c1["saturation"] < 0.15 or c2["saturation"] < 0.15:
continue
# Check if lightness difference alone saves them.
if abs(c1["lightness"] - c2["lightness"]) > 0.3:
continue
for range_a, range_b in confusion_ranges:
a_in_a = _hue_in_range(c1["hue"], range_a)
b_in_b = _hue_in_range(c2["hue"], range_b)
a_in_b = _hue_in_range(c1["hue"], range_b)
b_in_a = _hue_in_range(c2["hue"], range_a)
if (a_in_a and b_in_b) or (a_in_b and b_in_a):
results["safe"] = False
results["issues"].append({
"type": cvd_type,
"color_a": c1["hex"],
"color_b": c2["hex"],
"severity": "warning",
"message": (
f"{c1['hex']} and {c2['hex']} may be "
f"indistinguishable for {cvd_type} viewers"
),
})
return results
def validate_palette(playbook: dict) -> list[dict]:
"""Run contrast and color-blind checks on all text/bg pairs in a playbook.
Examines visual_language.color_palette and overlays for text/background
combinations and chart_palette if present.
Args:
playbook: Loaded playbook dict.
Returns:
List of issue dicts with severity (error/warning/info).
"""
issues: list[dict] = []
palette = playbook.get("visual_language", {}).get("color_palette", {})
bg = palette.get("background", "#FFFFFF")
text = palette.get("text", "#000000")
muted = palette.get("muted")
# Check main text on background
result = validate_contrast(text, bg)
if not result["normal_text"]["AA"]:
issues.append({
"pair": f"text ({text}) on background ({bg})",
"ratio": result["ratio"],
"severity": "error",
"message": f"Fails WCAG AA for normal text (ratio {result['ratio']}:1, need 4.5:1)",
})
elif not result["normal_text"]["AAA"]:
issues.append({
"pair": f"text ({text}) on background ({bg})",
"ratio": result["ratio"],
"severity": "info",
"message": f"Passes AA but not AAA for normal text (ratio {result['ratio']}:1)",
})
# Check muted text on background
if muted:
result = validate_contrast(muted, bg)
if not result["large_text"]["AA"]:
issues.append({
"pair": f"muted ({muted}) on background ({bg})",
"ratio": result["ratio"],
"severity": "error",
"message": f"Muted text fails AA even for large text (ratio {result['ratio']}:1)",
})
elif not result["normal_text"]["AA"]:
issues.append({
"pair": f"muted ({muted}) on background ({bg})",
"ratio": result["ratio"],
"severity": "warning",
"message": f"Muted text fails AA for normal text (ratio {result['ratio']}:1, OK for large)",
})
# Check overlay text/bg pairs
overlays = playbook.get("overlays", {})
for overlay_name, overlay in overlays.items():
o_bg = overlay.get("bg")
o_text = overlay.get("text")
if o_bg and o_text:
# Composite alpha colors against page background
if _has_alpha(o_bg):
o_bg = _composite_alpha(o_bg, bg)
if _has_alpha(o_text):
o_text = _composite_alpha(o_text, bg)
result = validate_contrast(o_text, o_bg)
if not result["normal_text"]["AA"]:
issues.append({
"pair": f"overlay.{overlay_name}: text ({o_text}) on bg ({o_bg})",
"ratio": result["ratio"],
"severity": "error",
"message": (
f"Overlay '{overlay_name}' fails WCAG AA "
f"(ratio {result['ratio']}:1)"
),
})
# Color-blind safety on primary + accent + chart_palette
all_colors = []
all_colors.extend(palette.get("primary", []))
all_colors.extend(palette.get("accent", []))
chart_palette = playbook.get("visual_language", {}).get(
"color_palette", {}
).get("chart_palette") or playbook.get("chart_palette", [])
all_colors.extend(chart_palette)
if len(all_colors) >= 2:
cvd_result = check_color_blind_safety(all_colors)
for cvd_issue in cvd_result.get("issues", []):
issues.append({
"pair": f"{cvd_issue['color_a']} / {cvd_issue['color_b']}",
"severity": "warning",
"message": cvd_issue["message"],
})
return issues
def generate_harmony(base_hex: str, harmony_type: str) -> list[str]:
"""Generate a color harmony palette from a base color.
Uses HSL math to create harmonious palettes.
Args:
base_hex: Base color as hex string.
harmony_type: One of "complementary", "analogous", "triadic",
"split-complementary".
Returns:
List of hex color strings including the base color.
"""
h, s, l = _hex_to_hsl(base_hex)
if harmony_type == "complementary":
offsets = [0, 180]
elif harmony_type == "analogous":
offsets = [-30, 0, 30]
elif harmony_type == "triadic":
offsets = [0, 120, 240]
elif harmony_type == "split-complementary":
offsets = [0, 150, 210]
else:
raise ValueError(
f"Unknown harmony type: {harmony_type!r}. "
f"Choose from: complementary, analogous, triadic, split-complementary"
)
return [_hsl_to_hex((h + offset) % 360, s, l) for offset in offsets]
# ---------------------------------------------------------------------------
# D3.5.6 — Typography intelligence
# ---------------------------------------------------------------------------
# Named modular type scale ratios
TYPE_SCALE_RATIOS: dict[str, float] = {
"minor_second": 1.067,
"major_second": 1.125,
"minor_third": 1.2,
"major_third": 1.25,
"perfect_fourth": 1.333,
"golden": 1.618,
}
def compute_type_scale(
base_size: int, ratio: str = "major_third"
) -> dict:
"""Generate a modular type scale from a base size and ratio.
Produces sizes for caption, body, subheading, heading, display levels.
Args:
base_size: Base font size in pixels (e.g., 24 for video).
ratio: Named ratio string or a numeric string. Supported names:
minor_second (1.067), major_second (1.125), minor_third (1.2),
major_third (1.25), perfect_fourth (1.333), golden (1.618).
Returns:
Dict mapping role names to pixel sizes and the ratio used.
"""
if ratio in TYPE_SCALE_RATIOS:
r = TYPE_SCALE_RATIOS[ratio]
else:
try:
r = float(ratio)
except ValueError:
raise ValueError(
f"Unknown type scale ratio: {ratio!r}. "
f"Choose from: {', '.join(TYPE_SCALE_RATIOS.keys())} or a number."
)
scale = {
"ratio_name": ratio if ratio in TYPE_SCALE_RATIOS else "custom",
"ratio_value": round(r, 4),
"base_size_px": base_size,
"sizes": {
"caption": round(base_size / r),
"body": base_size,
"subheading": round(base_size * r),
"heading": round(base_size * r ** 2),
"display": round(base_size * r ** 3),
},
}
return scale
def validate_type_hierarchy(playbook: dict) -> list[dict]:
"""Validate that typography sizes follow a clear hierarchy.
Checks that heading > subheading > body > caption sizes are properly
ordered with sufficient contrast between levels.
Args:
playbook: Loaded playbook dict.
Returns:
List of issue dicts. Empty list means hierarchy is valid.
"""
issues: list[dict] = []
typography = playbook.get("typography", {})
# Extract size multipliers (or infer relative weights from weight values)
roles = ["headings", "body", "code", "stat_card"]
role_weights: dict[str, int] = {}
role_multipliers: dict[str, float] = {}
for role in roles:
spec = typography.get(role, {})
if spec:
role_weights[role] = spec.get("weight", 400)
role_multipliers[role] = spec.get("size_multiplier", 1.0)
# Check that headings weight >= body weight
head_w = role_weights.get("headings", 700)
body_w = role_weights.get("body", 400)
if head_w <= body_w:
issues.append({
"roles": "headings vs body",
"severity": "warning",
"message": (
f"Heading weight ({head_w}) should be greater than "
f"body weight ({body_w}) for clear hierarchy"
),
})
# Check that stat_card multiplier > 1.0 (should be larger than body)
stat_mult = role_multipliers.get("stat_card", 1.0)
if stat_mult <= 1.0:
issues.append({
"roles": "stat_card",
"severity": "warning",
"message": (
f"stat_card size_multiplier ({stat_mult}) should be > 1.0 "
f"for visual prominence"
),
})
# Check weight differentiation between heading and body is sufficient
if head_w - body_w < 200:
issues.append({
"roles": "headings vs body",
"severity": "info",
"message": (
f"Weight difference between headings ({head_w}) and "
f"body ({body_w}) is only {head_w - body_w}. "
f"Consider >= 200 difference for clear visual separation."
),
})
# Check scale_system if present
scale_system = typography.get("scale_system")
if scale_system and scale_system in TYPE_SCALE_RATIOS:
ratio = TYPE_SCALE_RATIOS[scale_system]
if ratio < 1.1:
issues.append({
"roles": "scale_system",
"severity": "info",
"message": (
f"Scale ratio '{scale_system}' ({ratio}) is very tight. "
f"Consider a larger ratio for video content."
),
})
return issues
# Known-good font pairings database
_FONT_PAIRINGS: dict[str, list[dict]] = {
# Sans-serif fonts
"Inter": [
{
"font": "Lora",
"category": "serif",
"rationale": "Geometric sans + transitional serif. High x-height match.",
},
{
"font": "Playfair Display",
"category": "serif",
"rationale": "Clean sans + high-contrast serif for elegant contrast.",
},
{
"font": "JetBrains Mono",
"category": "monospace",
"rationale": "Matched x-height for code blocks alongside Inter body text.",
},
],
"Space Grotesk": [
{
"font": "Space Mono",
"category": "monospace",
"rationale": "Same type family. Unified geometric DNA.",
},
{
"font": "DM Serif Display",
"category": "serif",
"rationale": "Bold serif headlines with geometric sans body.",
},
{
"font": "Fira Code",
"category": "monospace",
"rationale": "Ligature-rich code font pairs well with geometric body text.",
},
],
"IBM Plex Sans": [
{
"font": "IBM Plex Serif",
"category": "serif",
"rationale": "Same type family. Perfect metric compatibility.",
},
{
"font": "IBM Plex Mono",
"category": "monospace",
"rationale": "Same type family. Unified design language for technical content.",
},
{
"font": "Merriweather",
"category": "serif",
"rationale": "Humanist sans + humanist serif. Both optimized for screen readability.",
},
],
# Serif fonts
"Lora": [
{
"font": "Inter",
"category": "sans-serif",
"rationale": "Transitional serif + geometric sans. Clean modern pairing.",
},
{
"font": "Source Sans Pro",
"category": "sans-serif",
"rationale": "Classic serif + humanist sans. Traditional yet readable.",
},
],
"Playfair Display": [
{
"font": "Source Sans Pro",
"category": "sans-serif",
"rationale": "High-contrast display serif + neutral sans-serif body.",
},
{
"font": "Raleway",
"category": "sans-serif",
"rationale": "Elegant serif + thin geometric sans for luxury feel.",
},
],
# Monospace fonts
"JetBrains Mono": [
{
"font": "Inter",
"category": "sans-serif",
"rationale": "Matched x-height. Both designed for screen readability.",
},
],
"Fira Code": [
{
"font": "Fira Sans",
"category": "sans-serif",
"rationale": "Same type family. Unified design language.",
},
{
"font": "Space Grotesk",
"category": "sans-serif",
"rationale": "Both geometric with similar proportions.",
},
],
}
# Generic fallback suggestions by font category keywords
_CATEGORY_PAIRINGS: dict[str, list[dict]] = {
"sans": [
{
"font": "Lora",
"category": "serif",
"rationale": "A versatile serif that pairs well with most sans-serif fonts.",
},
{
"font": "Source Serif Pro",
"category": "serif",
"rationale": "Neutral serif with excellent readability alongside sans-serif.",
},
],
"serif": [
{
"font": "Inter",
"category": "sans-serif",
"rationale": "Clean geometric sans-serif that complements most serif fonts.",
},
{
"font": "Source Sans Pro",
"category": "sans-serif",
"rationale": "Humanist sans-serif with broad serif compatibility.",
},
],
"mono": [
{
"font": "Inter",
"category": "sans-serif",
"rationale": "Versatile sans-serif body text alongside monospace code.",
},
],
}
def suggest_font_pairing(primary_font: str) -> list[dict]:
"""Suggest complementary fonts for a given primary font.
Returns known-good pairings from a curated database, falling back to
category-based suggestions for unknown fonts.
Args:
primary_font: Primary font name (e.g., "Inter").
Returns:
List of dicts with font, category, and rationale.
"""
# Direct lookup
if primary_font in _FONT_PAIRINGS:
return _FONT_PAIRINGS[primary_font]
# Category-based fallback
font_lower = primary_font.lower()
if "mono" in font_lower or "code" in font_lower:
return _CATEGORY_PAIRINGS["mono"]
elif "serif" in font_lower and "sans" not in font_lower:
return _CATEGORY_PAIRINGS["serif"]
else:
return _CATEGORY_PAIRINGS["sans"]
# ---------------------------------------------------------------------------
# D3.5.7 — Accessibility validation
# ---------------------------------------------------------------------------
# Minimum font size in pixels for video content readability
MIN_VIDEO_BODY_SIZE_PX = 24
def validate_accessibility(playbook: dict) -> dict:
"""Comprehensive accessibility validation for a playbook.
Checks:
- WCAG contrast ratios for all text/background pairs
- Minimum font sizes for video readability (24px body minimum)
- Color-blind safety for chart palettes and accent colors
- Overlay text contrast
Designed to be callable as a pre-render validation step.
Args:
playbook: Loaded playbook dict.
Returns:
Dict with overall pass/fail, score, and categorized issues.
"""
issues: list[dict] = []
# --- Contrast checks (reuse validate_palette) ---
palette_issues = validate_palette(playbook)
for pi in palette_issues:
issues.append({
"category": "contrast" if "ratio" in pi else "color_blind",
**pi,
})
# --- Font size checks ---
typography = playbook.get("typography", {})
body_spec = typography.get("body", {})
body_base_size = body_spec.get("size_multiplier", 1.0) * MIN_VIDEO_BODY_SIZE_PX
# We check the scale_system if present to compute actual sizes
scale_system = typography.get("scale_system")
if scale_system:
scale = compute_type_scale(MIN_VIDEO_BODY_SIZE_PX, scale_system)
sizes = scale["sizes"]
if sizes["caption"] < 16:
issues.append({
"category": "font_size",
"severity": "warning",
"message": (
f"Caption size ({sizes['caption']}px) is below 16px. "
f"May be unreadable on mobile video."
),
})
else:
# No scale system — just check multiplier conventions
stat_mult = typography.get("stat_card", {}).get("size_multiplier", 1.0)
if stat_mult < 2.0:
issues.append({
"category": "font_size",
"severity": "info",
"message": (
f"stat_card size_multiplier ({stat_mult}) is modest. "
f"Consider >= 2.0x for video stat cards."
),
})
# --- Type hierarchy checks ---
hierarchy_issues = validate_type_hierarchy(playbook)
for hi in hierarchy_issues:
issues.append({"category": "typography", **hi})
# --- Chart palette color-blind check ---
chart_palette = playbook.get("chart_palette", [])
if chart_palette and len(chart_palette) >= 2:
cvd_result = check_color_blind_safety(chart_palette)
if not cvd_result["safe"]:
for ci in cvd_result["issues"]:
issues.append({
"category": "color_blind",
"severity": "warning",
"message": ci["message"],
})
# --- Weight matrix checks ---
weight_matrix = typography.get("weight_matrix", {})
if weight_matrix:
expected_order = ["title", "heading", "body", "caption"]
prev_weight = 1000
for role in expected_order:
w = weight_matrix.get(role)
if w is not None and w > prev_weight:
issues.append({
"category": "typography",
"severity": "warning",
"message": (
f"Weight matrix: '{role}' weight ({w}) should not "
f"exceed the weight of higher-priority roles."
),
})
if w is not None:
prev_weight = w
# --- Compute overall result ---
error_count = sum(1 for i in issues if i.get("severity") == "error")
warning_count = sum(1 for i in issues if i.get("severity") == "warning")
return {
"pass": error_count == 0,
"error_count": error_count,
"warning_count": warning_count,
"total_issues": len(issues),
"issues": issues,
}