#!/usr/bin/env python3 """review_html_renderer.py - Render parsed diffs + annotations into a 2-column HTML review. Stdlib-only. Combines: - diff_parser.py output (hunks per file) - annotation_extractor.py output (severity-tagged margin notes) - design-system config (12-token palette + typography + design_style) into a single-file HTML page with: * Top jump-nav: every annotation listed with severity badge + file:line + 1-line preview (Click jumps to the annotation in the right margin and highlights the hunk) * 2-column layout: diff on left, annotation cards on right (Falls back to stacked layout on viewports < 900px) * Per-line diff coloring: additions in success-tinted bg, deletions in warn-tinted bg * Severity badges: icon + color + aria-label (WCAG 1.4.1 — color is NOT the sole signal) * Mandatory "Reviewer:" footer (refuses to render without --reviewer) NO LLM CALLS. Pure templating + config-driven CSS. Single-file output: all CSS + (optional) JS inline. Only external is Google Fonts CSS for typography (same discipline as md-document; no Prism here — we render diff coloring ourselves with stable conventions). Usage: python review_html_renderer.py \\ --diff-blocks hunks.json \\ --annotations annotations.json \\ --reviewer "Jane Doe" \\ --output review.html python review_html_renderer.py --sample --reviewer "Sample Reviewer" --output /tmp/review.html """ from __future__ import annotations import argparse import html import json import os import sys from pathlib import Path from typing import Any _DESIGN_SYSTEM_SCRIPTS = ( Path(__file__).resolve().parent.parent.parent / "design-system" / "scripts" ) sys.path.insert(0, str(_DESIGN_SYSTEM_SCRIPTS)) try: import config_loader as _cfg import brand_palette_validator as _bpv except ImportError: _cfg = None _bpv = None # ----- Severity → visual mapping (color + icon + aria-label) ------------------- # Color is NOT the sole signal (WCAG 1.4.1). Every badge has an icon and an # aria-label that's announced by screen readers. SEVERITY_DEFAULTS = { # severity_name: {token_key_in_palette, icon_text, aria_phrase} "BLOCKER": {"token": "_danger", "icon": "■", "aria": "Blocker — must fix before merge"}, "MAJOR": {"token": "--md-warn", "icon": "▲", "aria": "Major — strongly recommended fix"}, "MINOR": {"token": "--md-link", "icon": "●", "aria": "Minor — worth fixing"}, "NIT": {"token": "--md-text-muted", "icon": "◦", "aria": "Nit — cosmetic preference"}, } def _derive_danger_color(palette: dict[str, str]) -> str: """Compute a 'danger' color by rotating the accent hue toward red. Falls back to a generic red if the palette is unavailable. """ if not palette or _bpv is None: return "#D04646" accent_hex = palette.get("--md-accent", "#D04646") try: rgb = _bpv.parse_hex(accent_hex) # Rotate hue toward red (0°) — pick the shorter rotation that lands near red target = _bpv.shift_hue(rgb, -120) # rotate 120° toward red return _bpv.rgb_to_hex(target) except Exception: return "#D04646" def _resolve_severity_color(severity: str, palette: dict[str, str], danger: str) -> str: sev = severity.upper() spec = SEVERITY_DEFAULTS.get(sev, {"token": "--md-text-muted"}) token = spec["token"] if token == "_danger": return danger return palette.get(token, "#888888") def _palette_to_css(palette: dict[str, str]) -> str: if not palette: palette = { "--md-bg": "#0E1E38", "--md-surface": "#142B50", "--md-border": "#1A3868", "--md-text": "#F7F7F2", "--md-text-muted": "rgba(247, 247, 242, 0.68)", "--md-accent": "#00D4AA", "--md-accent-soft": "rgba(0, 212, 170, 0.14)", "--md-code-bg": "#122648", "--md-link": "#00D4AA", "--md-link-hover": "#08FECE", "--md-success": "#10A85C", "--md-warn": "#C87C10", } return "\n".join(f" {k}: {v};" for k, v in palette.items()) def _font_url(heading: str, body: str) -> str: families = sorted({heading, body}) parts = "&".join(f"family={f.replace(' ', '+')}:wght@400;600" for f in families) return f"https://fonts.googleapis.com/css2?{parts}&display=swap" def _font_stack(name: str) -> str: fallback = ("Georgia, serif" if name in ("Playfair Display", "Merriweather", "Lora", "Source Serif 4") else "system-ui, -apple-system, sans-serif") return f"'{name}', {fallback}" BASE_CSS = """ :root { __PALETTE__ --md-danger: __DANGER__; --md-font-heading: __HEADING_FONT__; --md-font-body: __BODY_FONT__; --md-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; } * { box-sizing: border-box; } html { scroll-behavior: smooth; } body { margin: 0; padding: 2rem 1.5rem; background: var(--md-bg); color: var(--md-text); font-family: var(--md-font-body); font-size: 16px; line-height: 1.55; max-width: 1400px; margin-left: auto; margin-right: auto; } h1, h2, h3 { font-family: var(--md-font-heading); color: var(--md-text); margin: 0 0 0.5em; line-height: 1.25; } h1 { font-size: 1.75rem; } h2 { font-size: 1.25rem; margin-top: 2rem; padding-bottom: 0.3em; border-bottom: 1px solid var(--md-border); } a { color: var(--md-link); } /* Severity badges (color + icon + aria-label — WCAG 1.4.1) */ .sev-badge { display: inline-flex; align-items: center; gap: 0.35em; font-family: var(--md-font-heading); font-weight: 600; font-size: 0.75rem; letter-spacing: 0.05em; padding: 0.15em 0.55em; border-radius: 999px; border: 1px solid currentColor; background: var(--md-bg); } .sev-icon { font-family: var(--md-font-mono); font-size: 0.875em; } /* Top jump-nav */ nav.jump-nav { background: var(--md-surface); border: 1px solid var(--md-border); border-radius: 10px; padding: 1rem 1.25rem; margin: 1.5rem 0 2rem; } nav.jump-nav h2 { margin-top: 0; font-size: 1rem; border: none; padding: 0; } nav.jump-nav ul { list-style: none; padding: 0; margin: 0.75rem 0 0; display: grid; gap: 0.4rem; } nav.jump-nav li { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; } nav.jump-nav a { color: var(--md-text); text-decoration: none; flex: 1; border-radius: 4px; padding: 0.2em 0.4em; } nav.jump-nav a:hover { background: var(--md-accent-soft); color: var(--md-accent); } nav.jump-nav .nav-target { color: var(--md-text-muted); font-family: var(--md-font-mono); font-size: 0.875rem; } nav.jump-nav .nav-preview { color: var(--md-text-muted); font-size: 0.875rem; } /* 2-column hunk layout */ .hunk-row { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 1.25rem; margin: 1.5rem 0 2rem; scroll-margin-top: 1rem; } @media (max-width: 900px) { .hunk-row { grid-template-columns: 1fr; } .hunk-annotations { order: 2; } } .hunk { background: var(--md-code-bg); border: 1px solid var(--md-border); border-radius: 8px; overflow: hidden; } .hunk-head { background: var(--md-surface); border-bottom: 1px solid var(--md-border); padding: 0.5rem 0.85rem; font-family: var(--md-font-mono); font-size: 0.8125rem; color: var(--md-text-muted); display: flex; flex-wrap: wrap; gap: 0.75rem; } .hunk-head strong { color: var(--md-text); font-weight: 600; } .hunk pre { margin: 0; padding: 0; background: transparent; font-family: var(--md-font-mono); font-size: 0.8125rem; line-height: 1.55; overflow-x: auto; } .hunk .line { display: grid; grid-template-columns: 3.5em 3.5em 1.25em 1fr; padding-right: 0.75rem; } .hunk .ln, .hunk .lo { color: var(--md-text-muted); padding: 0 0.5em; text-align: right; user-select: none; border-right: 1px solid var(--md-border); font-size: 0.75rem; } .hunk .mark { text-align: center; user-select: none; color: var(--md-text-muted); } .hunk .src { padding-left: 0.5em; white-space: pre; overflow-wrap: normal; } .hunk .line.addition { background: color-mix(in srgb, var(--md-success) 15%, transparent); } .hunk .line.addition .mark { color: var(--md-success); } .hunk .line.deletion { background: color-mix(in srgb, var(--md-warn) 12%, transparent); } .hunk .line.deletion .mark { color: var(--md-warn); } .hunk .line.meta { color: var(--md-text-muted); font-style: italic; padding-left: 1em; } /* Annotation cards on the right */ .hunk-annotations { display: grid; gap: 0.75rem; align-content: start; } .annotation { background: var(--md-surface); border: 1px solid var(--md-border); border-left-width: 4px; border-radius: 8px; padding: 0.75rem 0.85rem; } .annotation .annotation-head { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; margin-bottom: 0.4rem; } .annotation .annotation-body { font-size: 0.9375rem; line-height: 1.5; color: var(--md-text); } .annotation .annotation-body code { font-family: var(--md-font-mono); background: var(--md-code-bg); padding: 0.1em 0.3em; border-radius: 4px; font-size: 0.875em; } .annotation.unanchored { border-left-color: var(--md-text-muted); } /* Reviewer footer */ footer.review-footer { margin-top: 3rem; padding-top: 1.5rem; border-top: 1px solid var(--md-border); color: var(--md-text-muted); font-size: 0.9375rem; display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; } footer.review-footer strong { color: var(--md-text); } /* Approval bar (LGTM markers) */ .approval-bar { background: color-mix(in srgb, var(--md-success) 12%, transparent); border: 1px solid var(--md-success); color: var(--md-success); padding: 0.5rem 0.85rem; border-radius: 8px; margin: 1rem 0; font-weight: 600; font-family: var(--md-font-heading); } @media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } html { scroll-behavior: auto; } } """ def _hunk_anchor(block_idx: int, file_idx: int, hunk_idx: int) -> str: return f"hunk-b{block_idx}-f{file_idx}-h{hunk_idx}" def _annotation_anchor(idx: int) -> str: return f"ann-{idx}" def _render_severity_badge(severity: str, palette: dict[str, str], danger: str) -> str: spec = SEVERITY_DEFAULTS.get(severity.upper(), {"icon": "?", "aria": severity}) color = _resolve_severity_color(severity, palette, danger) icon = html.escape(spec["icon"]) aria = html.escape(spec["aria"]) return ( f'' f'' f'{html.escape(severity.upper())}' f'' ) def _render_hunk(file_idx: int, file_entry: dict[str, Any], block_idx: int, hunk_idx: int) -> str: h = file_entry["hunks"][hunk_idx] path = file_entry.get("path_new") or file_entry.get("path_old") or "(unknown)" anchor = _hunk_anchor(block_idx, file_idx, hunk_idx) head = ( f'
' f'{html.escape(path)}' f'@{h["old_start"]}{html.escape(" → ")}@{h["new_start"]}' f'{html.escape(h.get("header_context") or "")}' f'
' ) body_lines = [] for ln in h["lines"]: kind = ln["kind"] mark = {"addition": "+", "deletion": "−", "context": " ", "meta": "\\"}.get(kind, " ") old = "" if ln.get("old") is None else str(ln["old"]) new = "" if ln.get("new") is None else str(ln["new"]) body_lines.append( f'
' f'{old}' f'{new}' f'' f'{html.escape(ln["text"])}' f'
' ) return ( f'
' f'{head}
{"".join(body_lines)}
' ) def _render_annotation(idx: int, ann: dict[str, Any], palette: dict[str, str], danger: str, anchor_for_block: dict[int, str]) -> str: color = _resolve_severity_color(ann["severity"], palette, danger) anchor_target = anchor_for_block.get(ann.get("attached_block")) target_link = ( f'jump to diff →' if anchor_target else "" ) body = html.escape(ann["body"]) # Re-inflate inline `code` for readability body = body.replace("`", "", 1) while "" in body and body.count("") > body.count(""): body = body.replace("`", "", 1) return ( f'
' f'
' f'{_render_severity_badge(ann["severity"], palette, danger)}' f'{target_link}' f'
' f'
{body}
' f'
' ) def render( diff_blocks: dict[str, Any], annotations: dict[str, Any], config: dict[str, Any], reviewer: str, pr_title: str = "Code Review", ) -> str: palette = config.get("derived_palette") or {} typo = config.get("typography") or {} heading_font = typo.get("heading_font", "Inter") body_font = typo.get("body_font", "Inter") style = config.get("design_style", "technical") company_name = config.get("company_name", "") danger = _derive_danger_color(palette) css = (BASE_CSS .replace("__PALETTE__", _palette_to_css(palette)) .replace("__DANGER__", danger) .replace("__HEADING_FONT__", _font_stack(heading_font)) .replace("__BODY_FONT__", _font_stack(body_font))) # Build anchor map for jump-nav targets anchor_for_block: dict[int, str] = {} for block in diff_blocks.get("blocks", []): block_idx = block["block_index"] for file_idx, file_entry in enumerate(block["files"]): if file_entry["hunks"]: # Anchor the first hunk of the first file in each block anchor_for_block.setdefault( block_idx, _hunk_anchor(block_idx, file_idx, 0) ) # Top jump-nav (ordered by source position, which is annotation order) annlist = annotations.get("annotations", []) nav_items_html: list[str] = [] for i, ann in enumerate(annlist): target_anchor = _annotation_anchor(i) target_diff = anchor_for_block.get(ann.get("attached_block")) or target_anchor preview = html.escape(ann["body"][:80] + ("…" if len(ann["body"]) > 80 else "")) target_label = ( f'#{ann.get("attached_block") + 1}' if ann.get("attached_block") is not None else "(unanchored)" ) nav_items_html.append( f'
  • ' f'{_render_severity_badge(ann["severity"], palette, danger)}' f'{preview}' f'{html.escape(target_label)}' f'
  • ' ) jump_nav_html = "" if annlist: counts = annotations.get("summary", {}).get("counts_by_severity", {}) count_summary = " · ".join( f"{n} {sev}" for sev, n in sorted(counts.items()) ) jump_nav_html = ( '' ) # Approval bar (LGTM markers) approvals = annotations.get("approvals", []) approval_html = "" if approvals and not annlist: approval_html = ( '
    ' 'LGTM — no findings flagged' '
    ' ) # Render hunks + their attached annotations block_to_annotations: dict[int, list[tuple[int, dict[str, Any]]]] = {} unanchored: list[tuple[int, dict[str, Any]]] = [] for i, ann in enumerate(annlist): if ann.get("attached_block") is not None: block_to_annotations.setdefault(ann["attached_block"], []).append((i, ann)) else: unanchored.append((i, ann)) sections_html: list[str] = [] for block in diff_blocks.get("blocks", []): block_idx = block["block_index"] anns_for_block = block_to_annotations.get(block_idx, []) for file_idx, file_entry in enumerate(block["files"]): for hunk_idx in range(len(file_entry["hunks"])): hunk_html = _render_hunk(file_idx, file_entry, block_idx, hunk_idx) # All annotations for this block render alongside the first hunk ann_html = "" if file_idx == 0 and hunk_idx == 0 and anns_for_block: ann_html = "".join( _render_annotation(i, ann, palette, danger, anchor_for_block) for i, ann in anns_for_block ) sections_html.append( '
    ' f'{hunk_html}' f'
    {ann_html}
    ' '
    ' ) if unanchored: sections_html.append('

    General comments

    ') sections_html.append('
    ') for i, ann in unanchored: sections_html.append( _render_annotation(i, ann, palette, danger, anchor_for_block) ) sections_html.append('
    ') # Footer with mandatory reviewer name footer_html = ( '' ) return f""" {html.escape(pr_title)}

    {html.escape(pr_title)}

    {jump_nav_html} {approval_html} {"".join(sections_html)} {footer_html} """ def main(argv: list[str]) -> int: p = argparse.ArgumentParser(description=__doc__.split("\n")[0]) p.add_argument("--diff-blocks", help="Path to diff_parser JSON output") p.add_argument("--annotations", help="Path to annotation_extractor JSON output") p.add_argument("--reviewer", help="Reviewer name (required; refuses to render without)") p.add_argument("--title", default="Code Review", help="PR / review title") p.add_argument("--output", help="Path to write HTML (else stdout)") p.add_argument("--no-config", action="store_true", help="Bypass design-system config (use DEFAULTS)") p.add_argument("--sample", action="store_true", help="Render the built-in sample PR review") p.add_argument("--severity-convention", default="BLOCKER,MAJOR,MINOR,NIT", help="Comma-separated severity tier list (most → least)") args = p.parse_args(argv) if args.sample: sys.path.insert(0, str(Path(__file__).resolve().parent)) import annotation_extractor import diff_parser text = diff_parser.SAMPLE_MARKDOWN diff_blocks = diff_parser.parse_markdown_for_diffs(text) sev_conv = [s.strip().upper() for s in args.severity_convention.split(",")] annotations = annotation_extractor.extract_annotations(text, diff_blocks, sev_conv) reviewer = args.reviewer or "Sample Reviewer" pr_title = args.title else: if not (args.diff_blocks and args.annotations): print("error: --diff-blocks and --annotations are required " "(use --sample for a built-in demo)", file=sys.stderr) return 2 diff_blocks = json.loads(Path(args.diff_blocks).read_text(encoding="utf-8")) annotations = json.loads(Path(args.annotations).read_text(encoding="utf-8")) reviewer = args.reviewer pr_title = args.title # Hard rule 1: reviewer name is mandatory (named owner per research-ops discipline) if not reviewer or not reviewer.strip(): print("refusing: --reviewer is required. A code review must name a human reviewer.", file=sys.stderr) return 3 # Hard rule 2: refuse if there are no hunks (wrong skill — route to md-document) if diff_blocks.get("summary", {}).get("total_hunks", 0) == 0: print("refusing: no diff hunks present in input. This is not a code review — " "route to md-document instead.", file=sys.stderr) return 4 if args.no_config or os.environ.get("MARKDOWN_HTML_NO_CONFIG") == "1": config = _cfg.DEFAULTS if _cfg else {} else: config = _cfg.load_config() if _cfg else {} output = render(diff_blocks, annotations, config, reviewer, pr_title) if args.output and args.output != "-": Path(args.output).write_text(output, encoding="utf-8") print(f"wrote {args.output}: {len(output):,} bytes " f"({diff_blocks['summary']['total_hunks']} hunks, " f"{annotations['summary']['total_annotations']} annotations, " f"reviewer={reviewer})") else: print(output) return 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))