ui-copilot / backend /services /css_generator.py
ksri77's picture
ci: add lint step (ruff), enable ruff+httpx in requirements, fix all lint warnings
e9aa55c
Raw
History Blame Contribute Delete
8.93 kB
"""
Module 10 — CSS Generator.
Generates CSS variables (design tokens) and targeted fix blocks from an
AnalysisResult. No API calls — pure text generation from the parsed data
and detected issues.
"""
from __future__ import annotations
from backend.models.analysis import AnalysisResult
from backend.utils.config_loader import load_scoring_config
def generate(result: AnalysisResult) -> str:
"""Return a ready-to-paste CSS snippet from an AnalysisResult."""
cfg = load_scoring_config()["thresholds"]
lines: list[str] = []
lines.append("/* ============================================================")
lines.append(" UICopilot — Generated CSS Fixes")
lines.append(f" Overall score: {result.overall_score}/100")
lines.append(" Copy relevant blocks into your stylesheet.")
lines.append(" ============================================================ */")
lines.append("")
_append_tokens(lines, result, cfg)
_append_typography_fixes(lines, result, cfg)
_append_spacing_fixes(lines, result, cfg)
_append_button_fixes(lines, result, cfg)
_append_form_fixes(lines, result, cfg)
_append_table_fixes(lines, result, cfg)
return "\n".join(lines)
# ── section builders ──────────────────────────────────────────────────────────
def _append_tokens(lines: list[str], result: AnalysisResult, cfg: dict) -> None:
t_sp = cfg["spacing"]
base = t_sp["grid_base_px"]
lines.append("/* === Design Tokens === */")
lines.append(":root {")
lines.append(f" /* Spacing scale ({base}pt grid) */")
for mult in (0.5, 1, 1.5, 2, 3, 4, 6):
val = int(base * mult)
lines.append(f" --space-{val}: {val}px;")
lines.append("")
# Typography tokens
t_ty = cfg["typography"]
lines.append(" /* Typography */")
lines.append(f" --font-size-body: {t_ty['recommended_body_size_px']}px;")
lines.append(f" --line-height-body: {t_ty['line_height_min']};")
lines.append(" --font-family-body: system-ui, -apple-system, sans-serif;")
lines.append(" --font-family-mono: ui-monospace, 'Cascadia Code', monospace;")
lines.append("")
# Button tokens
t_bt = cfg["buttons"]
t_sp2 = cfg["spacing"]
lines.append(" /* Buttons */")
lines.append(f" --btn-padding-y: {t_sp2['button_min_padding_y_px']}px;")
lines.append(f" --btn-padding-x: {t_sp2['button_min_padding_x_px']}px;")
lines.append(f" --btn-min-height: {t_bt['min_touch_target_px']}px;")
lines.append(" --btn-radius: 6px;")
lines.append(" --btn-primary-bg: #005fcc;")
lines.append(" --btn-primary-color: #ffffff;")
lines.append(" --btn-focus-outline: 2px solid #005fcc;")
lines.append(" --btn-focus-offset: 2px;")
lines.append("")
# Card tokens
t_cards = cfg["spacing"]
lines.append(" /* Cards */")
lines.append(f" --card-padding: {t_cards['card_min_padding_px']}px;")
lines.append(" --card-radius: 8px;")
lines.append(" --card-border: 1px solid #e5e7eb;")
lines.append("}")
lines.append("")
def _append_typography_fixes(lines: list[str], result: AnalysisResult, cfg: dict) -> None:
issues_by_id = {i.rule_id for i in result.issues}
if not any(r in issues_by_id for r in ("T1_body_font_size", "T2_font_family_count",
"T3_line_height", "T4_heading_hierarchy",
"T5_heading_size")):
return
t = cfg["typography"]
lines.append("/* === Typography Fixes === */")
if "T1_body_font_size" in issues_by_id or "T3_line_height" in issues_by_id:
lines.append("body, p, li, td, th {")
lines.append(" font-size: var(--font-size-body);")
lines.append(" line-height: var(--line-height-body);")
lines.append(" font-family: var(--font-family-body);")
lines.append("}")
lines.append("")
if "T4_heading_hierarchy" in issues_by_id or "T5_heading_size" in issues_by_id:
min_h = t["min_heading_size_px"]
lines.append(f"h1 {{ font-size: {int(min_h * 1.8)}px; font-weight: 700; }}")
lines.append(f"h2 {{ font-size: {int(min_h * 1.4)}px; font-weight: 600; }}")
lines.append(f"h3 {{ font-size: {int(min_h * 1.15)}px; font-weight: 600; }}")
lines.append("")
def _append_spacing_fixes(lines: list[str], result: AnalysisResult, cfg: dict) -> None:
issues_by_id = {i.rule_id for i in result.issues}
if not any(r in issues_by_id for r in ("S1_button_padding", "S2_card_padding",
"S3_off_grid_spacing")):
return
lines.append("/* === Spacing Fixes === */")
if "S2_card_padding" in issues_by_id:
lines.append(".card, [class*='card'] {")
lines.append(" padding: var(--card-padding);")
lines.append(" border-radius: var(--card-radius);")
lines.append(" border: var(--card-border);")
lines.append("}")
lines.append("")
def _append_button_fixes(lines: list[str], result: AnalysisResult, cfg: dict) -> None:
issues_by_id = {i.rule_id for i in result.issues}
if not any(r in issues_by_id for r in ("S1_button_padding", "B1_button_style_count",
"B2_border_radius_variance", "B3_focus_style",
"B4_touch_target")):
return
lines.append("/* === Button Fixes === */")
lines.append("button, [type='button'], [type='submit'], .btn {")
lines.append(" padding: var(--btn-padding-y) var(--btn-padding-x);")
lines.append(" min-height: var(--btn-min-height);")
lines.append(" border-radius: var(--btn-radius);")
lines.append(" font-size: var(--font-size-body);")
lines.append(" cursor: pointer;")
lines.append("}")
lines.append("")
if "B3_focus_style" in issues_by_id:
lines.append("button:focus-visible,")
lines.append("[type='button']:focus-visible,")
lines.append("[type='submit']:focus-visible,")
lines.append(".btn:focus-visible {")
lines.append(" outline: var(--btn-focus-outline);")
lines.append(" outline-offset: var(--btn-focus-offset);")
lines.append("}")
lines.append("")
if "B1_button_style_count" in issues_by_id:
lines.append(".btn-primary {")
lines.append(" background: var(--btn-primary-bg);")
lines.append(" color: var(--btn-primary-color);")
lines.append(" border: none;")
lines.append("}")
lines.append("")
lines.append(".btn-secondary {")
lines.append(" background: transparent;")
lines.append(" color: var(--btn-primary-bg);")
lines.append(" border: 1px solid var(--btn-primary-bg);")
lines.append("}")
lines.append("")
def _append_form_fixes(lines: list[str], result: AnalysisResult, cfg: dict) -> None:
issues_by_id = {i.rule_id for i in result.issues}
if not any(r in issues_by_id for r in ("F1_missing_label", "F2_placeholder_as_label",
"F3_input_padding")):
return
t = cfg["forms"]
lines.append("/* === Form Fixes === */")
lines.append("label {")
lines.append(" display: block;")
lines.append(" font-size: var(--font-size-body);")
lines.append(" font-weight: 500;")
lines.append(" margin-bottom: 4px;")
lines.append("}")
lines.append("")
if "F3_input_padding" in issues_by_id:
lines.append("input, select, textarea {")
lines.append(f" padding: {t['min_input_padding_px']}px 12px;")
lines.append(" font-size: var(--font-size-body);")
lines.append(" border: 1px solid #d1d5db;")
lines.append(" border-radius: 4px;")
lines.append(" width: 100%;")
lines.append("}")
lines.append("")
def _append_table_fixes(lines: list[str], result: AnalysisResult, cfg: dict) -> None:
issues_by_id = {i.rule_id for i in result.issues}
if not any(r in issues_by_id for r in ("TBL1_missing_header", "TBL2_no_zebra",
"TBL3_cell_padding")):
return
t = cfg["tables"]
lines.append("/* === Table Fixes === */")
lines.append("table { border-collapse: collapse; width: 100%; }")
lines.append(f"td, th {{ padding: {t['min_cell_padding_px']}px 12px; text-align: left; }}")
if "TBL1_missing_header" in issues_by_id:
lines.append("th {")
lines.append(" font-weight: 600;")
lines.append(" background: #f3f4f6;")
lines.append(" border-bottom: 2px solid #e5e7eb;")
lines.append("}")
lines.append("")
if "TBL2_no_zebra" in issues_by_id:
lines.append("tr:nth-child(even) { background: #f9fafb; }")
lines.append("")