feat: freeform palier 4 - derives de tokens, shapes, alpha, rotation, full_bleed (C6)

This commit is contained in:
2026-07-12 08:14:57 +02:00
parent 6e8167a91c
commit 3929d14e9f
6 changed files with 1160 additions and 70 deletions
+83 -15
View File
@@ -1143,11 +1143,19 @@ def list_assets(proj: "Project"):
VALID_TOKENS = {"navy", "navy_light", "coral", "glacier", "slate",
"card", "white", "body", "muted"}
VALID_BLOCK_TYPES = {"title", "heading", "text", "stat", "circle",
"badge", "card", "rect", "line", "image"}
"badge", "card", "rect", "line", "image", "shape"}
VALID_FREE_SHAPES = {"chevron", "arrow", "triangle", "pill", "donut",
"bracket_left", "bracket_right", "oval", "diamond",
"hexagon", "parallelogram", "moon"}
FREE_TOKEN_RE = re.compile(
r"^(navy|navy_light|coral|glacier|slate|card|card_alt|white|body|muted)"
r"(@\d{1,3})?$")
def validate_freeform(raw: str):
"""Valide la structure d'un YAML freeform. Retourne (ok, message, data)."""
"""Valide un YAML freeform — palier 4 (C6), deux niveaux.
Retourne (ok, message, data). Les avertissements n'empêchent pas
le rendu ; ils sont intégrés au message."""
try:
data = yaml.safe_load(extract_yaml(raw))
except yaml.YAMLError as e:
@@ -1157,35 +1165,95 @@ def validate_freeform(raw: str):
slides = data["slides"]
if not slides:
return False, "Aucune slide.", None
errors = []
def color_ok(c):
return bool(FREE_TOKEN_RE.match(str(c).strip().lower()))
errors, warns = [], []
for i, s in enumerate(slides, 1):
if s.get("layout") != "freeform":
errors.append(f"Slide {i} : layout doit être 'freeform'")
continue
if s.get("mode") not in ("light", "dark"):
errors.append(f"Slide {i} : mode doit être 'light' ou 'dark'")
if s.get("mode") not in ("light", "dark", None):
errors.append(f"Slide {i} : mode doit être 'light' ou "
f"'dark'")
bg = s.get("background")
if bg and not color_ok(bg):
errors.append(f"Slide {i} : background '{bg}' hors charte "
f"(token ou token@NN)")
blocks = s.get("blocks", [])
if not blocks:
errors.append(f"Slide {i} : aucun bloc")
if len(blocks) > 8:
errors.append(f"Slide {i} : {len(blocks)} blocs (max 8)")
if len(blocks) > 15:
errors.append(f"Slide {i} : {len(blocks)} blocs (max 15)")
elif len(blocks) > 10:
warns.append(f"Slide {i} : {len(blocks)} blocs — pense "
f"respiration (10 max conseillé)")
for j, b in enumerate(blocks, 1):
loc = f"Slide {i} bloc {j}"
bt = (b.get("type") or "text").lower()
if bt not in VALID_BLOCK_TYPES:
errors.append(f"Slide {i} bloc {j} : type '{bt}' invalide")
errors.append(f"{loc} : type '{bt}' invalide")
if bt == "shape":
sk = str(b.get("shape") or "").lower()
if sk and sk not in VALID_FREE_SHAPES:
errors.append(
f"{loc} : shape '{sk}' inconnue "
f"({', '.join(sorted(VALID_FREE_SHAPES))})")
col = float(b.get("col", 0)); w = float(b.get("w", 4))
row = float(b.get("row", 0)); h = float(b.get("h", 1))
if col + w > 12.01:
errors.append(f"Slide {i} bloc {j} : déborde (col+w={col+w:.1f}>12)")
errors.append(f"{loc} : déborde "
f"(col+w={col + w:.1f}>12)")
if row + h > 12.01:
errors.append(f"Slide {i} bloc {j} : déborde (row+h={row+h:.1f}>12)")
color = b.get("color")
if color and color not in VALID_TOKENS:
errors.append(f"Slide {i} bloc {j} : couleur '{color}' hors charte")
errors.append(f"{loc} : déborde "
f"(row+h={row + h:.1f}>12)")
for key in ("color", "text_color"):
c = b.get(key)
if c and not color_ok(c):
errors.append(f"{loc} : {key} '{c}' hors charte "
f"(token ou token@NN, jamais de hex)")
border = b.get("border")
if border is not None:
if not isinstance(border, dict):
errors.append(f"{loc} : border doit être "
f"{{color, weight}}")
elif border.get("color") and not color_ok(border["color"]):
errors.append(f"{loc} : border.color "
f"'{border['color']}' hors charte")
alpha = b.get("alpha")
if alpha is not None:
try:
if not 0 <= float(alpha) <= 100:
warns.append(f"{loc} : alpha {alpha} hors "
f"0-100 — sera clampé")
except (TypeError, ValueError):
errors.append(f"{loc} : alpha '{alpha}' non "
f"numérique")
rot = b.get("rotation")
if rot is not None:
try:
if float(rot) % 15 != 0:
warns.append(f"{loc} : rotation {rot}° — sera "
f"arrondie au pas de 15°")
except (TypeError, ValueError):
errors.append(f"{loc} : rotation '{rot}' non "
f"numérique")
radius = b.get("radius")
if radius is not None:
try:
if not 0 <= float(radius) <= 0.5:
warns.append(f"{loc} : radius {radius} hors "
f"0-0.5 — sera clampé")
except (TypeError, ValueError):
errors.append(f"{loc} : radius '{radius}' non "
f"numérique")
if errors:
return False, "\n ".join(errors), data
return True, f"{len(slides)} slides freeform valides.", data
msg = f"{len(slides)} slides freeform valides."
if warns:
msg += "\n" + "\n".join(warns)
return True, msg, data
def run_free_designer(session: AgentSession, markdown: str, proj: Project):
"""Flux libre : le Free Designer produit directement le YAML freeform."""