#!/usr/bin/env python3
"""
design_engine.py — Aesthetic computation engine for art-direction-first PDF production.
Philosophy: The LLM handles narrative and composition; this script handles the math
that models get wrong — precise color harmony, algorithmic SVG, and spatial tension.
Three core functions:
1. generate_color_palette(intent) — HSL-locked low-saturation palettes
2. generate_generative_svg(svg_type) — Algorithmic art backgrounds
3. calculate_layout(elements) — Deliberate offset/overlap coordinates
Usage:
python3 design_engine.py palette --intent calm --mode dark
python3 design_engine.py palette --intent tension --mode light
python3 design_engine.py svg --intent flow --dimensions 720x960
python3 design_engine.py svg --intent grid --dimensions 720x960
python3 design_engine.py layout --elements hero,body,meta --dimensions 720x960 --style offset
python3 design_engine.py full --intent energy --mode dark --dimensions 720x960 --output-dir ./assets/
"""
import argparse
import colorsys
import json
import math
import os
import random
import sys
# ═══════════════════════════════════════════════════════════════════════
# 1. COLOR PALETTE — HSL with Saturation Clamped to [0.05, 0.25]
# ═══════════════════════════════════════════════════════════════════════
#
# Core constraint: Saturation MUST be between 0.05 and 0.25.
# This eliminates candy-colored, web-app-looking outputs and locks
# everything into the "high-end grey" (高级灰) tonal range.
#
# Two modes:
# - Dark: background L < 0.10, text L > 0.85
# - Light: background L > 0.95, text L < 0.15
# The "muddy middle" (L between 0.30 and 0.70) is forbidden for backgrounds.
# Intent → base hue mapping (degrees on the HSL wheel)
# 5 intents: Calm, Tension, Energy, Authority, Warmth
# Plus utility intents (nature, cold, neutral) for keyword auto-derive
INTENT_HUES = {
"calm": 210, # Steel blue-grey, low saturation (merges old serenity+minimalism)
"tension": 0, # Warm near-black vs cold
"energy": 30, # Amber undertone
"authority": 280, # Muted violet, formal/premium (replaces old elegance)
"warmth": 20, # Terracotta undertone
# Utility intents (for keyword auto-derive, not exposed in UI)
"nature": 150, # Desaturated sage
"cold": 200, # Slate blue
"neutral": 45, # Warm grey
# Legacy aliases (backward compatibility)
"serenity": 210, # → calm
"elegance": 280, # → authority
"minimalism": 0, # → calm (achromatic variant)
}
# Theme keywords → intent mapping (for auto-derive from document description)
# When the user doesn't specify an intent, scan the document title/description
# for these keywords and map to the closest intent.
THEME_KEYWORDS = {
# Technology / Data / Analytics
"tech": "cold", "数据": "cold", "data": "cold", "AI": "cold",
"科技": "cold", "digital": "cold", "analytics": "cold", "分析": "cold",
# Nature / Environment / Sustainability
"green": "nature", "绿色": "nature", "环保": "nature", "eco": "nature",
"sustainability": "nature", "生态": "nature", "forest": "nature",
# Business / Finance / Corporate
"report": "neutral", "报告": "neutral", "finance": "neutral", "财务": "neutral",
"annual": "neutral", "年度": "neutral", "corporate": "neutral",
# Creative / Marketing / Social
"marketing": "energy", "运营": "energy", "social": "energy", "品牌": "energy",
"campaign": "energy", "活动": "energy", "launch": "energy",
# Authority / Formal / Premium / Luxury
"luxury": "authority", "奢华": "authority", "fashion": "authority", "时尚": "authority",
"premium": "authority", "高端": "authority", "gala": "authority",
"formal": "authority", "正式": "authority", "professional": "authority", "专业": "authority",
"government": "authority", "政府": "authority", "bidding": "authority", "投标": "authority",
"政府报告": "authority", "政府文书": "authority", "公文": "authority",
"thesis": "authority", "毕业论文": "authority", "dissertation": "authority",
"开题": "authority", "开题报告": "authority", "proposal": "authority", "学位": "authority",
# Calm / Meditation / Healthcare / Minimalist
"health": "calm", "健康": "calm", "meditation": "calm",
"wellness": "calm", "calm": "calm", "医疗": "calm",
"minimalist": "calm", "极简": "calm", "simple": "calm", "简约": "calm",
# Urgent / Warning / Emergency
"urgent": "tension", "warning": "tension", "紧急": "tension",
"alert": "tension", "crisis": "tension",
# Warm / Food / Lifestyle
"food": "warmth", "美食": "warmth", "lifestyle": "warmth",
"生活": "warmth", "home": "warmth", "家居": "warmth",
}
def derive_intent(text):
"""
Auto-derive design intent from document title/description.
Scans for theme keywords and returns the best-matching intent.
Falls back to 'neutral' if no keywords match.
Usage:
python3 design_engine.py derive "Social Media Operations Monthly Report" → energy
python3 design_engine.py derive "2025 Annual Sustainability Report" → nature
"""
text_lower = text.lower()
scores = {}
for keyword, intent in THEME_KEYWORDS.items():
if keyword.lower() in text_lower:
scores[intent] = scores.get(intent, 0) + 1
if not scores:
return "neutral"
# When tied, prefer specific intents over 'neutral' (which is a generic fallback)
max_score = max(scores.values())
top_intents = [k for k, v in scores.items() if v == max_score]
if len(top_intents) > 1 and "neutral" in top_intents:
top_intents.remove("neutral")
return top_intents[0]
# Intent → recommended harmony mapping (reduces LLM decision burden)
# Used as fallback when LLM doesn't specify color_harmony
INTENT_HARMONY_MAP = {
"calm": "analogous", # peaceful, flowing transition (merges serenity+minimalism)
"tension": "complementary", # maximum visual conflict
"energy": "triadic", # vibrant, multi-directional
"authority": "split_complementary", # sophisticated, formal (replaces elegance)
"warmth": "analogous", # natural, earthy cohesion
# Utility intents
"nature": "analogous", # organic harmony
"cold": "split_complementary", # icy precision with subtle warmth
"neutral": "split_complementary", # safe default, still interesting
# Legacy aliases
"serenity": "analogous",
"elegance": "split_complementary",
"minimalism": "monochrome",
}
def _hsl_to_hex(h, s, l):
"""Convert HSL (h: 0-360, s: 0-1, l: 0-1) to hex string."""
r, g, b = colorsys.hls_to_rgb(h / 360.0, l, s)
return f"#{int(r*255):02x}{int(g*255):02x}{int(b*255):02x}"
# Muddy / ugly hue zones that produce unattractive accent colors at medium saturation.
# These hue ranges tend to look "dirty" or "sickly" — yellow-green, brown-orange, etc.
# When an accent lands here, we nudge it to the nearest attractive neighbor.
_UGLY_HUE_ZONES = [
# (start, end, nudge_low, nudge_high) — hues that look muddy
# at S 0.4-0.7, L 0.35-0.55. When accent lands here, redirect.
(28, 105, 15, 120), # Dirty yellow/brown/olive/yellow-green zone
# nudge_low=15 (warm red-orange), nudge_high=120 (true green)
]
def _sanitize_accent_hue(hue):
"""Nudge accent hue away from muddy/ugly zones toward attractive neighbors."""
hue = hue % 360
for start, end, nudge_low, nudge_high in _UGLY_HUE_ZONES:
if start <= hue <= end:
mid = (start + end) / 2
return nudge_low if hue < mid else nudge_high
return hue
def _hex_to_rgb(hex_str):
"""Convert hex to 'r,g,b' string for rgba() usage."""
hex_str = hex_str.lstrip('#')
return f"{int(hex_str[0:2], 16)},{int(hex_str[2:4], 16)},{int(hex_str[4:6], 16)}"
def _relative_luminance(hex_str):
"""WCAG 2.1 relative luminance from hex color."""
hex_str = hex_str.lstrip('#')
channels = []
for i in (0, 2, 4):
c = int(hex_str[i:i+2], 16) / 255.0
channels.append(c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4)
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]
def _contrast_ratio(hex1, hex2):
"""WCAG contrast ratio between two hex colors."""
l1, l2 = _relative_luminance(hex1), _relative_luminance(hex2)
lighter, darker = max(l1, l2), min(l1, l2)
return (lighter + 0.05) / (darker + 0.05)
def generate_color_palette(intent="neutral", mode="minimal", harmony=None, seed=None):
"""
Generative Color Harmony Engine — geometric accent computation + 5 aesthetic modes.
LLM is FORBIDDEN from specifying HEX/RGB. It only selects:
- intent → base hue (from INTENT_HUES)
- mode → S/L physical boundaries (minimal/dark/pastel/jewel/light)
- harmony → accent hue geometry (auto-recommended from intent if omitted)
Returns dict with:
- bg: 60% — dominant ground
- mid: 30% — structural shade
- accent: 10% — geometric harmony emphasis
- text: primary text color
- muted: secondary/caption text
- surface: card/container background (translucent)
"""
if seed is not None:
random.seed(seed)
# Auto-recommend harmony from intent if not specified
if harmony is None or harmony == "auto":
harmony = INTENT_HARMONY_MAP.get(intent, "split_complementary")
base_hue = INTENT_HUES.get(intent, random.randint(0, 359))
# 1. Geometric Accent Hue Derivation
if harmony == "complementary":
accent_hue = (base_hue + 180) % 360
elif harmony == "split_complementary":
accent_hue = (base_hue + random.choice([150, 210])) % 360
elif harmony == "triadic":
accent_hue = (base_hue + random.choice([120, 240])) % 360
elif harmony == "analogous":
accent_hue = (base_hue + random.choice([30, -30, 45, -45])) % 360
else: # monochrome
accent_hue = base_hue
# Sanitize accent hue — avoid muddy yellow-green and brown-orange zones
accent_hue = _sanitize_accent_hue(accent_hue)
# 2. Five Aesthetic Modes — hard-lock S and L boundaries
if mode == "minimal":
# DEFAULT for 50%+ of documents. Editorial paper tone + high-purity accent.
# bg has visible tint (S 0.08-0.15, L 0.92-0.96) — not dead white.
is_warm = random.choice([True, False])
paper_hue = random.randint(35, 45) if is_warm else random.randint(200, 215)
bg = _hsl_to_hex(paper_hue, random.uniform(0.08, 0.15), random.uniform(0.92, 0.96))
mid = _hsl_to_hex(paper_hue, random.uniform(0.10, 0.18), random.uniform(0.85, 0.90))
accent = _hsl_to_hex(accent_hue, random.uniform(0.55, 0.72), random.uniform(0.42, 0.52))
text = _hsl_to_hex(paper_hue, 0.05, random.uniform(0.10, 0.15))
muted = _hsl_to_hex(paper_hue, 0.05, random.uniform(0.45, 0.55))
surface = f"rgba(0,0,0,{random.uniform(0.01, 0.03):.2f})"
elif mode == "dark":
# Cyber/tech: ultra-low saturation, ultra-low lightness
bg = _hsl_to_hex(base_hue, random.uniform(0.04, 0.10), random.uniform(0.04, 0.08))
mid = _hsl_to_hex(base_hue, random.uniform(0.08, 0.15), random.uniform(0.12, 0.20))
accent = _hsl_to_hex(accent_hue, random.uniform(0.45, 0.60), random.uniform(0.52, 0.62))
text = _hsl_to_hex(base_hue, 0.05, random.uniform(0.88, 0.95))
muted = _hsl_to_hex(base_hue, 0.05, random.uniform(0.45, 0.55))
surface = f"rgba(255,255,255,{random.uniform(0.03, 0.06):.2f})"
elif mode == "pastel":
# Morandi/macaron: medium-low saturation, high lightness
# Accent tightened: S capped at 0.50 to avoid clashing with tinted bg
bg = _hsl_to_hex(base_hue, random.uniform(0.15, 0.35), random.uniform(0.85, 0.92))
mid = _hsl_to_hex(base_hue, random.uniform(0.20, 0.40), random.uniform(0.75, 0.82))
accent = _hsl_to_hex(accent_hue, random.uniform(0.38, 0.50), random.uniform(0.38, 0.48))
text = _hsl_to_hex(base_hue, 0.15, random.uniform(0.15, 0.25))
muted = _hsl_to_hex(base_hue, 0.20, random.uniform(0.45, 0.55))
surface = f"rgba(255,255,255,{random.uniform(0.30, 0.50):.2f})"
elif mode == "jewel":
# Gem/luxury: medium-high saturation bg, accent must NOT compete — lower S, higher L
bg = _hsl_to_hex(base_hue, random.uniform(0.40, 0.60), random.uniform(0.15, 0.25))
mid = _hsl_to_hex(base_hue, random.uniform(0.40, 0.60), random.uniform(0.25, 0.35))
accent = _hsl_to_hex(accent_hue, random.uniform(0.30, 0.50), random.uniform(0.65, 0.80))
text = _hsl_to_hex(base_hue, 0.10, random.uniform(0.90, 0.96))
muted = _hsl_to_hex(base_hue, 0.20, random.uniform(0.60, 0.70))
surface = f"rgba(0,0,0,{random.uniform(0.20, 0.40):.2f})"
else:
# light — noticeably tinted, not dead white (S 0.10-0.20, L 0.91-0.95)
bg = _hsl_to_hex(base_hue, random.uniform(0.10, 0.20), random.uniform(0.91, 0.95))
mid = _hsl_to_hex(base_hue, random.uniform(0.15, 0.25), random.uniform(0.84, 0.90))
accent = _hsl_to_hex(accent_hue, random.uniform(0.35, 0.45), random.uniform(0.35, 0.45))
text = _hsl_to_hex(base_hue, 0.08, random.uniform(0.08, 0.15))
muted = _hsl_to_hex(base_hue, 0.05, random.uniform(0.45, 0.55))
surface = f"rgba(0,0,0,{random.uniform(0.02, 0.05):.2f})"
# 3. WCAG Contrast Safety Net — ensure text:bg ratio ≥ 4.5:1
text_cr = _contrast_ratio(text, bg)
if text_cr < 4.5:
# Push text darker (light modes) or lighter (dark modes)
r, g, b = (int(bg.lstrip('#')[i:i+2], 16) / 255.0 for i in (0, 2, 4))
_, bg_l, _ = colorsys.rgb_to_hls(r, g, b)
if bg_l > 0.5:
text = _hsl_to_hex(base_hue, 0.08, 0.08) # force near-black
else:
text = _hsl_to_hex(base_hue, 0.05, 0.95) # force near-white
# 4. Accent-on-bg visibility check — ensure accent stands out (ratio ≥ 3:1)
accent_cr = _contrast_ratio(accent, bg)
if accent_cr < 3.0:
# Nudge accent lightness away from bg
r, g, b = (int(bg.lstrip('#')[i:i+2], 16) / 255.0 for i in (0, 2, 4))
_, bg_l, _ = colorsys.rgb_to_hls(r, g, b)
target_l = 0.35 if bg_l > 0.5 else 0.70
r2, g2, b2 = (int(accent.lstrip('#')[i:i+2], 16) / 255.0 for i in (0, 2, 4))
_, _, accent_s = colorsys.rgb_to_hls(r2, g2, b2)
accent = _hsl_to_hex(accent_hue, accent_s, target_l)
return {
"bg": bg, "bg_rgb": _hex_to_rgb(bg),
"mid": mid, "mid_rgb": _hex_to_rgb(mid),
"accent": accent, "accent_rgb": _hex_to_rgb(accent),
"text": text, "muted": muted,
"surface": surface,
"meta": {
"intent": intent, "mode": mode, "harmony": harmony,
"base_hue": base_hue, "accent_hue": accent_hue,
"contrast": {
"text_on_bg": round(_contrast_ratio(text, bg), 2),
"accent_on_bg": round(_contrast_ratio(accent, bg), 2),
}
}
}
def palette_to_css(palette):
"""Convert palette dict to CSS custom properties."""
return f""":root {{
/* 60% ground */
--c-bg: {palette['bg']};
--c-bg-rgb: {palette['bg_rgb']};
/* 30% structure */
--c-mid: {palette['mid']};
--c-mid-rgb: {palette['mid_rgb']};
/* 10% emphasis */
--c-accent: {palette['accent']};
--c-accent-rgb: {palette['accent_rgb']};
/* Typography */
--c-text: {palette['text']};
--c-muted: {palette['muted']};
/* Surfaces */
--c-surface: {palette['surface']};
}}"""
# ═══════════════════════════════════════════════════════════════════════
# 1b. CASCADE PALETTE — Role-Based Unified Color System
# ═══════════════════════════════════════════════════════════════════════
#
# Iron Law: Area ∝ 1/Saturation.
# The larger the colored area, the LOWER its saturation must be.
#
# Role tiers by area usage:
# XL (>50% of page): page_bg, section_bg → S ≤ 0.08, near-white/near-black
# L (20-50%): card_bg, table_stripe → S ≤ 0.15
# M (5-20%): header_fill, sidebar → S ≤ 0.30
# S (1-5%): border, divider, icon → S ≤ 0.50
# XS (<1%): accent_dot, badge, tag → S up to 0.75 (the only place high-sat lives)
#
# Every color is derived from one base hue. No orphan colors.
# Tier saturation caps — enforced at generation AND audit time
CASCADE_TIER_CAPS = {
"xl": 0.08, # Page background, section background
"l": 0.15, # Card background, table stripe
"m": 0.30, # Header fill, sidebar, cover decorative blocks
"s": 0.50, # Borders, dividers, icons, chart grid lines
"xs": 0.75, # Accent dot, badge, tag, data point highlight
}
def generate_cascade_palette(intent="neutral", mode="minimal", harmony=None, seed=None):
"""
Generate a role-based palette cascade where every color is derived from one
base hue and saturation is inversely proportional to usage area.
Returns a dict with:
roles: Complete role table (12 roles, each with hex, hsl, tier, usage_hint)
cover: Subset of roles for cover page rendering
body: Subset of roles for body content
charts: Subset of roles for data visualization
semantic: Low-saturation semantic colors (success, warning, error, info)
meta: Generation metadata (intent, mode, harmony, base_hue, audit)
css: Ready-to-use CSS custom properties
reportlab: Ready-to-paste ReportLab Python code
"""
if seed is not None:
random.seed(seed)
if harmony is None or harmony == "auto":
harmony = INTENT_HARMONY_MAP.get(intent, "split_complementary")
base_hue = INTENT_HUES.get(intent, random.randint(0, 359))
# Geometric accent hue derivation (same logic as original)
if harmony == "complementary":
accent_hue = (base_hue + 180) % 360
elif harmony == "split_complementary":
accent_hue = (base_hue + random.choice([150, 210])) % 360
elif harmony == "triadic":
accent_hue = (base_hue + random.choice([120, 240])) % 360
elif harmony == "analogous":
accent_hue = (base_hue + random.choice([30, -30, 45, -45])) % 360
else: # monochrome
accent_hue = base_hue
# Sanitize accent hue — avoid muddy zones
accent_hue = _sanitize_accent_hue(accent_hue)
# Secondary hue — between base and accent, for chart variety
secondary_hue = _sanitize_accent_hue(
(base_hue + accent_hue) / 2 if accent_hue != base_hue else (base_hue + 30) % 360
)
# Mode-dependent lightness anchors
is_dark = mode == "dark"
if is_dark:
bg_l_range = (0.04, 0.08)
text_l = random.uniform(0.88, 0.95)
muted_l = random.uniform(0.50, 0.60)
elif mode == "jewel":
bg_l_range = (0.15, 0.25)
text_l = random.uniform(0.90, 0.96)
muted_l = random.uniform(0.60, 0.70)
else: # minimal, pastel, light
bg_l_range = (0.94, 0.97)
text_l = random.uniform(0.08, 0.15)
muted_l = random.uniform(0.45, 0.55)
# ── Generate all 12 roles ──
roles = {}
# XL tier: page bg, section bg
roles["page_bg"] = _make_role(
base_hue, random.uniform(0.03, 0.08), random.uniform(*bg_l_range),
"xl", "Page background, full-bleed areas")
roles["section_bg"] = _make_role(
base_hue, random.uniform(0.04, 0.08),
random.uniform(bg_l_range[0] - 0.03, bg_l_range[1] - 0.02) if not is_dark
else random.uniform(0.08, 0.14),
"xl", "Section background, alternating bands")
# L tier: card bg, table stripe
roles["card_bg"] = _make_role(
base_hue, random.uniform(0.06, 0.14),
random.uniform(0.90, 0.94) if not is_dark else random.uniform(0.10, 0.16),
"l", "Card/container background, table even rows")
roles["table_stripe"] = _make_role(
base_hue, random.uniform(0.05, 0.12),
random.uniform(0.92, 0.96) if not is_dark else random.uniform(0.08, 0.12),
"l", "Table alternating row fill")
# M tier: header fill, sidebar, cover decorative block
roles["header_fill"] = _make_role(
base_hue, random.uniform(0.15, 0.28),
random.uniform(0.25, 0.40) if not is_dark else random.uniform(0.20, 0.30),
"m", "Table header, sidebar background, cover top bar")
roles["cover_block"] = _make_role(
base_hue, random.uniform(0.12, 0.25),
random.uniform(0.30, 0.45) if not is_dark else random.uniform(0.15, 0.25),
"m", "Cover decorative block, sidebar pillar")
# S tier: border, divider, icon fill
roles["border"] = _make_role(
base_hue, random.uniform(0.10, 0.25),
random.uniform(0.70, 0.82) if not is_dark else random.uniform(0.25, 0.35),
"s", "Borders, divider lines, chart grid")
roles["icon"] = _make_role(
base_hue, random.uniform(0.25, 0.45),
random.uniform(0.35, 0.50) if not is_dark else random.uniform(0.55, 0.70),
"s", "Icons, bullet points, small UI elements")
# XS tier: accent, badge, data highlight
# DESIGN RULE: accent uses base_hue (same as header_fill) at higher saturation.
# This ensures cover structural elements, body headings, table headers, and
# accent highlights all belong to the same color family. The geometric
# accent_hue is only used for accent_secondary (chart series differentiation).
roles["accent"] = _make_role(
base_hue, random.uniform(0.50, 0.70),
random.uniform(0.40, 0.55) if not is_dark else random.uniform(0.55, 0.70),
"xs", "Primary accent: badges, tags, data point highlights, CTA")
roles["accent_secondary"] = _make_role(
accent_hue, random.uniform(0.40, 0.60),
random.uniform(0.45, 0.58) if not is_dark else random.uniform(0.50, 0.65),
"xs", "Secondary accent: chart series 2, secondary badge")
# Typography (no tier — text is special)
roles["text_primary"] = _make_role(
base_hue, 0.05, text_l,
"text", "Primary body text")
roles["text_muted"] = _make_role(
base_hue, 0.04, muted_l,
"text", "Captions, footnotes, secondary text")
# ── Semantic colors (derived from base hue, low-sat) ──
semantic = {
"success": _make_role(140, random.uniform(0.25, 0.40),
random.uniform(0.35, 0.45) if not is_dark else random.uniform(0.55, 0.65),
"xs", "Positive: growth, pass, complete"),
"warning": _make_role(40, random.uniform(0.30, 0.45),
random.uniform(0.40, 0.50) if not is_dark else random.uniform(0.55, 0.65),
"xs", "Caution: pending, alert"),
"error": _make_role(5, random.uniform(0.30, 0.45),
random.uniform(0.40, 0.50) if not is_dark else random.uniform(0.55, 0.65),
"xs", "Negative: decline, fail, error"),
"info": _make_role(210, random.uniform(0.25, 0.40),
random.uniform(0.40, 0.50) if not is_dark else random.uniform(0.55, 0.65),
"xs", "Informational: neutral status"),
}
# ── WCAG contrast enforcement ──
page_bg_hex = roles["page_bg"]["hex"]
text_hex = roles["text_primary"]["hex"]
if _contrast_ratio(text_hex, page_bg_hex) < 4.5:
if not is_dark:
roles["text_primary"] = _make_role(base_hue, 0.08, 0.08, "text", "Primary body text")
else:
roles["text_primary"] = _make_role(base_hue, 0.05, 0.95, "text", "Primary body text")
accent_hex = roles["accent"]["hex"]
if _contrast_ratio(accent_hex, page_bg_hex) < 3.0:
# Push accent lightness further from bg
r, g, b = (int(page_bg_hex.lstrip('#')[i:i+2], 16) / 255.0 for i in (0, 2, 4))
_, bg_l, _ = colorsys.rgb_to_hls(r, g, b)
target_l = 0.35 if bg_l > 0.5 else 0.75
roles["accent"] = _make_role(base_hue, random.uniform(0.50, 0.70), target_l,
"xs", "Primary accent: badges, tags, data point highlights, CTA")
# ── Tier saturation audit (clamp any violations) ──
for name, role in {**roles, **semantic}.items():
tier = role["tier"]
if tier in CASCADE_TIER_CAPS:
cap = CASCADE_TIER_CAPS[tier]
if role["hsl"][1] > cap:
# Re-generate with capped saturation
capped_s = cap * random.uniform(0.85, 1.0)
fixed = _make_role(role["hsl"][0], capped_s, role["hsl"][2],
tier, role["usage_hint"])
if name in roles:
roles[name] = fixed
elif name in semantic:
semantic[name] = fixed
# ── Build convenience subsets ──
cover_subset = {
"background": roles["page_bg"]["hex"],
"top_bar": roles["header_fill"]["hex"],
"sidebar": roles["cover_block"]["hex"],
"accent_line": roles["accent"]["hex"],
"title": roles["text_primary"]["hex"],
"subtitle": roles["text_muted"]["hex"],
"watermark": roles["border"]["hex"], # low-opacity usage
}
body_subset = {
"page_bg": roles["page_bg"]["hex"],
"section_bg": roles["section_bg"]["hex"],
"card_bg": roles["card_bg"]["hex"],
"table_header": roles["header_fill"]["hex"],
"table_stripe": roles["table_stripe"]["hex"],
"border": roles["border"]["hex"],
"heading": roles["text_primary"]["hex"],
"body_text": roles["text_primary"]["hex"],
"caption": roles["text_muted"]["hex"],
"highlight": roles["accent"]["hex"],
}
chart_subset = {
"series_1": roles["accent"]["hex"],
"series_2": roles["accent_secondary"]["hex"],
"series_3": roles["header_fill"]["hex"],
"series_4": roles["icon"]["hex"],
"series_5": roles["cover_block"]["hex"],
"grid": roles["border"]["hex"],
"axis_text": roles["text_muted"]["hex"],
"label": roles["text_primary"]["hex"],
"up": semantic["success"]["hex"],
"down": semantic["error"]["hex"],
}
# ── Generate outputs ──
css = _cascade_to_css(roles, semantic)
reportlab_code = _cascade_to_reportlab(roles, semantic)
audit_results = audit_cascade_palette(roles, semantic)
return {
"roles": {k: {"hex": v["hex"], "hsl": v["hsl"], "tier": v["tier"], "usage": v["usage_hint"]} for k, v in roles.items()},
"cover": cover_subset,
"body": body_subset,
"charts": chart_subset,
"semantic": {k: {"hex": v["hex"], "hsl": v["hsl"]} for k, v in semantic.items()},
"meta": {
"intent": intent, "mode": mode, "harmony": harmony,
"base_hue": base_hue, "accent_hue": accent_hue, "secondary_hue": secondary_hue,
"contrast": {
"text_on_bg": round(_contrast_ratio(roles["text_primary"]["hex"], roles["page_bg"]["hex"]), 2),
"accent_on_bg": round(_contrast_ratio(roles["accent"]["hex"], roles["page_bg"]["hex"]), 2),
},
"audit": audit_results,
},
"css": css,
"reportlab": reportlab_code,
}
def _make_role(h, s, l, tier, usage_hint):
"""Create a role entry with hex, HSL tuple, tier, and usage hint."""
hex_val = _hsl_to_hex(h, s, l)
return {
"hex": hex_val,
"hsl": (round(h, 1), round(s, 3), round(l, 3)),
"tier": tier,
"usage_hint": usage_hint,
}
def _cascade_to_css(roles, semantic):
"""Convert cascade palette to CSS custom properties."""
lines = [":root {"]
lines.append(" /* ── XL tier: backgrounds (S ≤ 0.08) ── */")
lines.append(f" --page-bg: {roles['page_bg']['hex']};")
lines.append(f" --section-bg: {roles['section_bg']['hex']};")
lines.append(" /* ── L tier: surfaces (S ≤ 0.15) ── */")
lines.append(f" --card-bg: {roles['card_bg']['hex']};")
lines.append(f" --table-stripe: {roles['table_stripe']['hex']};")
lines.append(" /* ── M tier: structural fills (S ≤ 0.30) ── */")
lines.append(f" --header-fill: {roles['header_fill']['hex']};")
lines.append(f" --cover-block: {roles['cover_block']['hex']};")
lines.append(" /* ── S tier: edges & icons (S ≤ 0.50) ── */")
lines.append(f" --border: {roles['border']['hex']};")
lines.append(f" --icon: {roles['icon']['hex']};")
lines.append(" /* ── XS tier: emphasis (S ≤ 0.75) ── */")
lines.append(f" --accent: {roles['accent']['hex']};")
lines.append(f" --accent-secondary: {roles['accent_secondary']['hex']};")
lines.append(" /* ── Typography ── */")
lines.append(f" --text-primary: {roles['text_primary']['hex']};")
lines.append(f" --text-muted: {roles['text_muted']['hex']};")
lines.append(" /* ── Semantic (low-sat) ── */")
for k, v in semantic.items():
lines.append(f" --semantic-{k}: {v['hex']};")
lines.append(" /* ── Cover template aliases (--c-* compat for cover.md templates 01-07) ── */")
lines.append(f" --c-bg: {roles['page_bg']['hex']};")
lines.append(f" --c-accent: {roles['accent']['hex']};")
lines.append(f" --c-text: {roles['text_primary']['hex']};")
lines.append(f" --c-muted: {roles['text_muted']['hex']};")
lines.append(f" --c-mid: {roles['header_fill']['hex']};")
lines.append(f" --c-surface: {roles['card_bg']['hex']};")
lines.append("}")
return "\n".join(lines)
def _cascade_to_reportlab(roles, semantic):
"""Convert cascade palette to ReportLab Python code."""
lines = [
"# ━━ Cascade Palette (auto-generated by design_engine.py palette-cascade) ━━",
"from reportlab.lib import colors",
"",
"# XL tier: backgrounds (area > 50%, S ≤ 0.08)",
f"PAGE_BG = colors.HexColor('{roles['page_bg']['hex']}')",
f"SECTION_BG = colors.HexColor('{roles['section_bg']['hex']}')",
"",
"# L tier: surfaces (area 20-50%, S ≤ 0.15)",
f"CARD_BG = colors.HexColor('{roles['card_bg']['hex']}')",
f"TABLE_STRIPE = colors.HexColor('{roles['table_stripe']['hex']}')",
"",
"# M tier: structural fills (area 5-20%, S ≤ 0.30)",
f"HEADER_FILL = colors.HexColor('{roles['header_fill']['hex']}')",
f"COVER_BLOCK = colors.HexColor('{roles['cover_block']['hex']}')",
"",
"# S tier: edges & icons (area 1-5%, S ≤ 0.50)",
f"BORDER = colors.HexColor('{roles['border']['hex']}')",
f"ICON = colors.HexColor('{roles['icon']['hex']}')",
"",
"# XS tier: emphasis (area < 1%, S ≤ 0.75)",
f"ACCENT = colors.HexColor('{roles['accent']['hex']}')",
f"ACCENT_2 = colors.HexColor('{roles['accent_secondary']['hex']}')",
"",
"# Typography",
f"TEXT_PRIMARY = colors.HexColor('{roles['text_primary']['hex']}')",
f"TEXT_MUTED = colors.HexColor('{roles['text_muted']['hex']}')",
"",
"# Semantic (low-saturation, area-appropriate)",
f"SEM_SUCCESS = colors.HexColor('{semantic['success']['hex']}')",
f"SEM_WARNING = colors.HexColor('{semantic['warning']['hex']}')",
f"SEM_ERROR = colors.HexColor('{semantic['error']['hex']}')",
f"SEM_INFO = colors.HexColor('{semantic['info']['hex']}')",
]
return "\n".join(lines)
def audit_cascade_palette(roles, semantic):
"""Audit cascade palette for tier saturation violations and WCAG contrast."""
violations = []
for name, role in {**roles, **semantic}.items():
tier = role["tier"]
if tier in CASCADE_TIER_CAPS:
cap = CASCADE_TIER_CAPS[tier]
s = role["hsl"][1]
if s > cap:
violations.append(f"{name}: S={s:.3f} exceeds tier '{tier}' cap {cap}")
# WCAG checks
bg_hex = roles["page_bg"]["hex"]
text_hex = roles["text_primary"]["hex"]
cr = _contrast_ratio(text_hex, bg_hex)
if cr < 4.5:
violations.append(f"text_primary:page_bg contrast {cr:.2f} < 4.5:1 (WCAG AA)")
accent_hex = roles["accent"]["hex"]
cr_a = _contrast_ratio(accent_hex, bg_hex)
if cr_a < 3.0:
violations.append(f"accent:page_bg contrast {cr_a:.2f} < 3.0:1 (accent invisible)")
# Header fill should be readable with white text on it
hf_hex = roles["header_fill"]["hex"]
cr_hf = _contrast_ratio("#ffffff", hf_hex)
if cr_hf < 3.0:
violations.append(f"white on header_fill contrast {cr_hf:.2f} < 3.0:1 (header text unreadable)")
return violations
# ═══════════════════════════════════════════════════════════════════════
# 2. GENERATIVE SVG — Algorithmic Art Backgrounds
# ═══════════════════════════════════════════════════════════════════════
#
# Two primary modes:
# - "flow": 3-5 large-radius bézier curves at ultra-low opacity (0.05)
# Creates organic, breathing atmospheric depth
# - "grid": 1px reference grid or noise texture
# Creates structured, architectural underlying rhythm
#
# All SVG is inline-ready (no external files needed).
def _svg_open(w, h):
return f''
return svg
def generate_grid_svg(w, h, color="#888888", spacing=60, line_width=0.5, opacity=0.04):
"""
Grid mode: architectural reference grid.
Ultra-faint 1px lines creating underlying structure.
"""
svg = _svg_open(w, h)
svg += f'\n \1', text)
# Anti-orphan: prevent single trailing character on last line
text = _prevent_orphan_chars(text)
html_parts.append(f'
{text}
') paragraph_buffer = [] def apply_inline(text): """Apply bold, italic, inline code.""" text = re.sub(r'\*\*(.*?)\*\*', r'\1', text) text = re.sub(r'\*(.*?)\*', r'\1', text) text = re.sub(r'`(.*?)`', r'\1', text)
return text
for line in lines:
stripped = line.strip()
# Empty line — flush paragraph
if not stripped:
flush_paragraph()
if in_list:
html_parts.append('')
in_list = False
continue
# Headers
if stripped.startswith('### '):
flush_paragraph()
if in_list:
html_parts.append('')
in_list = False
html_parts.append(f'{num_match.group(1)}. {apply_inline(num_match.group(2))}
') continue # Normal text — accumulate into paragraph paragraph_buffer.append(stripped) # Flush remaining flush_paragraph() if in_list: html_parts.append('') return '\n'.join(html_parts) def _parse_grid_area(comp): """ Parse grid_area from component JSON. Accepts two formats: - Array: [row_start, col_start, row_end, col_end] — 1-based, max 13. - String: "row_start / col_start / row_end / col_end" — 1-based, max 13. Returns CSS grid-area string or None. """ ga = comp.get("grid_area", None) if ga is None: return None # Array format: [1, 1, 3, 5] if isinstance(ga, list) and len(ga) == 4: rs, cs, re, ce = [max(1, min(13, int(v))) for v in ga] # Fix zero-height/zero-width grid areas (row_end must be > row_start) if re <= rs: re = min(13, rs + 1) if ce <= cs: ce = min(13, cs + 1) return f"{rs} / {cs} / {re} / {ce}" # String format: "1 / 1 / 3 / 5" if isinstance(ga, str) and "/" in ga: parts = [p.strip() for p in ga.split("/")] if len(parts) == 4: try: rs, cs, re, ce = [max(1, min(13, int(p))) for p in parts] # Fix zero-height/zero-width grid areas if re <= rs: re = min(13, rs + 1) if ce <= cs: ce = min(13, cs + 1) return f"{rs} / {cs} / {re} / {ce}" except ValueError: pass return None def _parse_align(comp): """ Parse align from component JSON. Format: "vertical / horizontal" where each is start|center|end. Returns (align-items, justify-content) tuple. """ align = comp.get("align", "start / start") if "/" in str(align): parts = [p.strip() for p in str(align).split("/")] v = parts[0] if parts[0] in ("start", "center", "end") else "start" h = parts[1] if len(parts) > 1 and parts[1] in ("start", "center", "end") else "start" else: v, h = "start", "start" return v, h def _estimate_content_weight(comp): """ Estimate the visual weight (space needed) of a component based on its content. Returns a numeric weight proportional to how many grid rows it should occupy. Text-heavy components (Glass_Canvas, Shaped_Canvas) get weight proportional to character count. Fixed-height components (Stat_Block, Hero_Typography, etc.) get a small fixed weight since they don't grow with content. """ ctype = comp.get("type", "") # Fixed-height components: their visual size is independent of text length FIXED_WEIGHTS = { "Hero_Typography": 2.5, "Stat_Block": 2.0, "Delta_Widget": 2.0, "Hairline_Divider": 1.0, "Floating_Meta": 1.0, # usually positioned in corners, but fallback "Image_Asset": 3.0, } if ctype in FIXED_WEIGHTS: return FIXED_WEIGHTS[ctype] # Text-heavy components: weight scales with content length text = comp.get("markdown_content", "") or comp.get("body", "") or "" # Also count sub-items (Process_List steps) for step in comp.get("steps", []): text += step.get("title", "") + step.get("description", "") char_count = len(text) if ctype in ("Glass_Canvas", "Shaped_Canvas", "Process_List"): # Rough estimate: ~40 CJK chars per line at 16px in a ~500px wide container, # ~1.6 line-height, so each 40 chars ≈ 1 visual line ≈ ~25px. # Grid row on a 960px canvas ≈ 80px each (960/12). # So 40 chars ≈ 0.3 grid rows. But Glass_Canvas has padding (~72px total), # headings, list spacing, etc. Add base weight for the container itself. base = 2.0 # minimum for padding + heading text_rows = char_count / 120.0 # ~120 chars per grid-row worth of space return base + text_rows # Unknown component type: estimate from text length with a reasonable default if char_count > 0: return 2.0 + char_count / 120.0 return 2.0 def _assign_floating_meta(comp): """Assign grid_area to a Floating_Meta component based on its position.""" pos = comp.get("position", "top-right") if "top" in pos and "left" in pos: comp["grid_area"] = "1 / 1 / 2 / 7" elif "top" in pos: comp["grid_area"] = "1 / 7 / 2 / 13" elif "bottom" in pos and "left" in pos: comp["grid_area"] = "12 / 1 / 13 / 7" else: comp["grid_area"] = "12 / 7 / 13 / 13" def _distribute_rows_by_weight(content_comps, start_row=1, end_row=13, full_width=True): """ Distribute grid rows among content components proportionally to their content weight. Each component gets at least 2 rows. Components fill start_row to end_row seamlessly. """ nn = len(content_comps) if nn == 0: return total_rows = end_row - start_row # available rows # Calculate weights weights = [_estimate_content_weight(c) for c in content_comps] total_weight = sum(weights) if total_weight == 0: total_weight = nn # fallback: equal distribution weights = [1.0] * nn # Allocate rows proportionally, with minimum 2 per component MIN_ROWS = 2 raw_rows = [(w / total_weight) * total_rows for w in weights] # Ensure minimum, then redistribute excess allocated = [] for r in raw_rows: allocated.append(max(MIN_ROWS, round(r))) # Adjust total to exactly fill the available rows # First pass: if total exceeds available, shrink the largest allocations while sum(allocated) > total_rows and any(a > MIN_ROWS for a in allocated): max_idx = max(range(nn), key=lambda i: allocated[i]) allocated[max_idx] -= 1 # Second pass: if total is less than available, grow the heaviest components while sum(allocated) < total_rows: max_weight_idx = max(range(nn), key=lambda i: weights[i]) allocated[max_weight_idx] += 1 # Assign grid_area col_start = 1 col_end = 13 if full_width else 7 # can be overridden by caller current_row = start_row for j, comp in enumerate(content_comps): rs = current_row re = min(end_row, current_row + allocated[j]) comp["grid_area"] = f"{rs} / {col_start} / {re} / {col_end}" current_row = re def _auto_assign_grid_areas(archetype, components): """ Auto-assign grid_area to components that don't have one, based on the archetype and number of components. Mutates components in-place. Skips Page_Ghost_Number (uses absolute positioning, not grid). Uses content-aware row distribution: text-heavy components (Glass_Canvas) get more rows than fixed-height components (Stat_Block, Hero_Typography). """ # Filter out ghost numbers — they use absolute positioning, not grid gridded = [c for c in components if c.get("type") != "Page_Ghost_Number"] # Only process components without grid_area needs_area = [c for c in gridded if not c.get("grid_area")] if not needs_area: return # All components already have grid_area n = len(gridded) if archetype == "cover_hero": # Cover: stack vertically, full width, spread across page # Typical: 2-4 components (Hero + Floating_Meta + optional divider/ghost) if n <= 2: slots = ["1 / 1 / 7 / 13", "9 / 1 / 13 / 13"] elif n == 3: slots = ["1 / 1 / 5 / 13", "5 / 1 / 9 / 13", "10 / 1 / 13 / 13"] else: slots = ["1 / 1 / 4 / 13", "4 / 1 / 7 / 13", "8 / 1 / 10 / 13", "10 / 1 / 13 / 13"] for i, comp in enumerate(gridded): if not comp.get("grid_area") and i < len(slots): comp["grid_area"] = slots[i] elif archetype == "data_dashboard": # Dashboard: tile components in a 2-column or 3-column grid has_area = [c for c in gridded if c.get("grid_area")] no_area = [c for c in gridded if not c.get("grid_area")] nn = len(no_area) if nn <= 2: slots = ["1 / 1 / 7 / 7", "1 / 7 / 7 / 13"] elif nn <= 4: slots = ["1 / 1 / 7 / 7", "1 / 7 / 7 / 13", "7 / 1 / 13 / 7", "7 / 7 / 13 / 13"] elif nn <= 6: slots = ["1 / 1 / 5 / 7", "1 / 7 / 5 / 13", "5 / 1 / 9 / 7", "5 / 7 / 9 / 13", "9 / 1 / 13 / 7", "9 / 7 / 13 / 13"] elif nn <= 9: slots = ["1 / 1 / 5 / 5", "1 / 5 / 5 / 9", "1 / 9 / 5 / 13", "5 / 1 / 9 / 5", "5 / 5 / 9 / 9", "5 / 9 / 9 / 13", "9 / 1 / 13 / 5", "9 / 5 / 13 / 9", "9 / 9 / 13 / 13"] else: # Too many: 3-col grid, auto-expand rows slots = [] cols = 3 row_h = max(2, 12 // ((nn + cols - 1) // cols)) for idx in range(nn): r = idx // cols c = idx % cols rs = 1 + r * row_h re = min(13, rs + row_h) cs = 1 + c * 4 ce = min(13, cs + 4) slots.append(f"{rs} / {cs} / {re} / {ce}") j = 0 for comp in no_area: if j < len(slots): comp["grid_area"] = slots[j] j += 1 elif archetype == "editorial_flow": # Editorial: stack vertically, full width # Handle Floating_Meta separately — assign to corners based on position # Content components get rows proportional to their content weight no_area = [c for c in gridded if not c.get("grid_area")] content_comps = [] for comp in no_area: if comp.get("type") == "Floating_Meta": _assign_floating_meta(comp) else: content_comps.append(comp) if content_comps: _distribute_rows_by_weight(content_comps, start_row=1, end_row=13, full_width=True) elif archetype == "split_vertical": # Split: first half on left, second half on right # Each side gets content-proportional row distribution no_area = [c for c in gridded if not c.get("grid_area")] nn = len(no_area) mid = (nn + 1) // 2 left = no_area[:mid] right = no_area[mid:] # Left column: cols 1-7 if left: weights_l = [_estimate_content_weight(c) for c in left] total_w = sum(weights_l) or 1 current = 1 for j, comp in enumerate(left): rows = max(2, round((weights_l[j] / total_w) * 12)) rs = current re = min(13, current + rows) comp["grid_area"] = f"{rs} / 1 / {re} / 7" current = re # Right column: cols 7-13 if right: weights_r = [_estimate_content_weight(c) for c in right] total_w = sum(weights_r) or 1 current = 1 for j, comp in enumerate(right): rows = max(2, round((weights_r[j] / total_w) * 12)) rs = current re = min(13, current + rows) comp["grid_area"] = f"{rs} / 7 / {re} / 13" current = re elif archetype == "scattered_canvas": # Scatter: distribute pseudo-randomly across the grid no_area = [c for c in gridded if not c.get("grid_area")] scatter_slots = [ "1 / 1 / 5 / 6", "1 / 7 / 4 / 13", "5 / 3 / 9 / 10", "6 / 1 / 10 / 5", "7 / 8 / 11 / 13", "10 / 2 / 13 / 8", "10 / 8 / 13 / 13", "3 / 1 / 6 / 5", "1 / 4 / 4 / 10", ] for j, comp in enumerate(no_area): if j < len(scatter_slots): comp["grid_area"] = scatter_slots[j] elif archetype == "shaped_editorial": # Shaped: main shaped canvas gets most of the page no_area = [c for c in gridded if not c.get("grid_area")] for j, comp in enumerate(no_area): if comp.get("type") == "Shaped_Canvas": comp["grid_area"] = "2 / 2 / 12 / 12" elif comp.get("type") == "Floating_Meta": _assign_floating_meta(comp) else: comp["grid_area"] = f"{1 + j * 3} / 1 / {min(13, 4 + j * 3)} / 13" # Fallback: if still no grid_area, make full-width stacked rows # Uses content-aware distribution instead of equal division remaining = [c for c in gridded if not c.get("grid_area")] if remaining: # First, handle Floating_Meta components by position non_meta = [] for comp in remaining: if comp.get("type") == "Floating_Meta": _assign_floating_meta(comp) else: non_meta.append(comp) # Then distribute rows proportionally to content weight if non_meta: _distribute_rows_by_weight(non_meta, start_row=1, end_row=13, full_width=True) def _wrap_grid_item(comp, inner_html): """Wrap a rendered component in a .grid-item div with grid positioning.""" grid_area_css = _parse_grid_area(comp) v_align, h_align = _parse_align(comp) # Glass_Canvas and Process_List should stretch to fill their grid area ctype = comp.get("type", "") if ctype in ("Glass_Canvas", "Process_List"): v_align = "stretch" style_parts = [] if grid_area_css: style_parts.append(f"grid-area: {grid_area_css}") style_parts.append(f"align-items: {v_align}") style_parts.append(f"justify-content: {h_align}") style = "; ".join(style_parts) + ";" return f'{subheading}
\n' elif ctype == "Glass_Canvas": md = comp.get("markdown_content", "") or comp.get("body", "") html_content = simple_markdown_to_html(md) # Build inline style from custom style props gs_parts = ["width:100%", "min-height:100%", "box-sizing:border-box"] # --- Auto font-size scaling for Glass_Canvas --- # When content is too long for the allocated grid rows, shrink font-size # to avoid overflow into adjacent components. # Estimation: ~80 chars per row at 16px base font-size (with padding). user_font_size = comp.get("font_size", "") if not user_font_size: # Only auto-scale when user hasn't set a custom size grid_area_str = comp.get("grid_area", "") if grid_area_str: try: ga_parts = [int(x.strip()) for x in grid_area_str.split("/")] allocated_rows = ga_parts[2] - ga_parts[0] # row_end - row_start content_len = len(md) chars_per_row = 80 # approximate chars that fit in one grid row at 16px needed_rows = max(1, content_len / chars_per_row) if needed_rows > allocated_rows: # Scale down proportionally, but never below 12px scale = allocated_rows / needed_rows new_size = max(12, int(16 * scale)) if new_size < 16: gs_parts.append(f"font-size: {new_size}px") except (ValueError, IndexError): pass for prop in ["background", "border", "border_radius", "padding", "font_size", "color", "line_height", "text_align"]: val = comp.get(prop, "") if val: css_prop = prop.replace("_", "-") gs_parts.append(f"{css_prop}: {val}") grid_style = "; ".join(gs_parts) + ";" tension = comp.get("tension_score", None) if tension is not None: weight = int(300 + (float(tension) * 600)) inner = f'