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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user