"""Trace Personality Bulletin — HTML rendering. The bulletin is a self-contained HTML document with embedded CSS, base64 assets, and an html2canvas-driven 'Save as PNG' button that screenshots the card client-side in the browser. No server-side image rendering, so the app deploys to a Hugging Face Space with zero extra system dependencies. Design adapted from the Anthropic Design handoff `Personality Report.html` (1080×1440 portrait, retro bulletin aesthetic). """ import base64 import html as html_mod from pathlib import Path _ASSETS = Path(__file__).parent / "assets" def _data_url(path: Path, mime: str = "image/png") -> str: return f"data:{mime};base64,{base64.b64encode(path.read_bytes()).decode()}" _SEAL_URL = _data_url(_ASSETS / "seal_small.png") _GRAIN_URL = _data_url(_ASSETS / "grain_small.png") _ROMAN = ["I", "II", "III", "IV", "V"] _NBSP = " " # --- Palette (Retro · Cream from the handoff) --- _INK = "#353E60" # hf-blue-deep (navy) _INK_SOFT = "rgba(53,62,96,0.7)" _SURFACE = "#FFF8DE" # hf-cream _ACCENT = "#00704A" # Starbucks-style green _ACCENT2 = "#FF9D00" # hf-orange _SUNBURST = "rgba(255,157,0,0.35)" _SINS_BG = "#353E60" _SINS_INK = "#FFF8DE" _SINS_ACCENT = "#FF9D00" _SINS_DOTS = "rgba(255,157,0,0.18)" _RED = "#DB3328" # hf-red — second offset on the title shadow _YELLOW = "#FFD21E" # --- Fonts (Google Fonts CDN) --- _FONTS_LINK = ( '' ) def _sunburst_svg(rays: int = 32, color: str = _SUNBURST) -> str: parts = [] for i in range(rays): a = (i * 360) / rays parts.append( f'' ) return ( '' + "".join(parts) + "" ) def _inner_html(data: dict) -> str: """Build the standalone HTML *document* that renders inside the srcdoc iframe. Wrapped by `bulletin_html` in an iframe so its CSS is isolated from Gradio. """ e = html_mod.escape user = e(str(data.get("user") or "")) arch1, arch2 = (data.get("archetype") or ["", ""])[:2] arch1 = e(str(arch1)) arch2 = e(str(arch2)) tagline = e(str(data.get("tagline") or "")) sins = data.get("sins") or [] forecast = data.get("forecast") or {} headline = e(str(forecast.get("headline") or "The week ahead")) body = str(forecast.get("body") or "") drop_cap = e(body[:1]) body_rest = e(body[1:]) dataset = e(str(data.get("dataset") or "")) turns = int(data.get("turns") or 0) span = e(str(data.get("span") or "")) generated = e(str(data.get("generated") or "")) serial = e(str(data.get("serial") or "")) # Build the sins list with ornamental dividers between each pair sin_blocks = [] for i, sin in enumerate(sins[:3]): if i > 0: sin_blocks.append( '
' '' '✺ ✺ ✺' '' "
" ) raw_meta = str(sin.get("meta") or "").strip() # Strip any quote marks the model may have wrapped around the quote # so the rendered card always uses the same smart-quote pair. raw_meta = raw_meta.strip("\"'“”‘’") meta_html = f'“{e(raw_meta)}”' if raw_meta and raw_meta != "—" else e(raw_meta) source = str(sin.get("source") or "").strip() if source: meta_html += f' · {e(source)}' sin_blocks.append( '
' f'
{e(_ROMAN[i])}.
' '
' f'

{e(str(sin.get("title") or ""))}

' f'{meta_html}' "
" ) sins_html = "".join(sin_blocks) return f""" Trace Personality Bulletin {_FONTS_LINK}
★ Presented by Hugging Face ★ Anno MMXXVI ★

Trace Personality Bulletin

This bulletin hereby certifies, after careful observation, that the operator behind the handle
@{user}
{_sunburst_svg()}
is

{arch1}
{arch2}

{tagline}

✺ Three Mortal Sins ✺
{sins_html}
✺ {headline} ✺

{drop_cap}{body_rest}

Hugging Face Roastery seal
Signed —
Hugging Face Roastery
Dated · {generated}· {turns:,} turns analysed· {dataset}· {serial}
""".strip() def bulletin_html(data: dict) -> str: """Wrap the standalone bulletin document in an iframe so its CSS is isolated from Gradio's theme. Returns an HTML snippet suitable for `gr.HTML`. The Save-as-PNG button lives in the parent (outside the iframe) so the iframe's `aspect-ratio: 1080/1440` matches its contents exactly — otherwise Gradio's dark theme bleeds in below the card. """ doc = _inner_html(data) # Escape for the srcdoc attribute value (double-quoted). Order matters: # & must be encoded first, then ". Inside the iframe the parser will # decode these back to literal '&' and '"' before parsing the HTML body. escaped = doc.replace("&", "&").replace('"', """) # Deterministic-ish id so two renders on the same page don't collide. frame_id = "tpb-frame-" + str(abs(hash(data.get("serial", "x"))) % 10**8) return f"""
""".strip() def empty_bulletin_html(message: str = "Awaiting bulletin…") -> str: """Lightweight placeholder shown before a report is generated.""" return f"""
{html_mod.escape(message)}
""".strip()