trace-reports / render.py
mervenoyan's picture
Add source session id to each sin and force smart-quote wrapping
fabd7ab
"""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 = (
'<link href="https://fonts.googleapis.com/css2?'
'family=Source+Sans+3:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500&'
'family=IBM+Plex+Mono:wght@400;500;600&'
'family=Alfa+Slab+One&display=swap" rel="stylesheet">'
)
def _sunburst_svg(rays: int = 32, color: str = _SUNBURST) -> str:
parts = []
for i in range(rays):
a = (i * 360) / rays
parts.append(
f'<rect x="99" y="0" width="2" height="100" fill="{color}" '
f'transform="rotate({a} 100 100)"/>'
)
return (
'<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg" '
'style="width:100%;height:100%;display:block;">'
+ "".join(parts)
+ "</svg>"
)
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(
'<div class="tpb-sin-divider">'
'<span class="tpb-rule"></span>'
'<span class="tpb-rule-glyph">✺ ✺ ✺</span>'
'<span class="tpb-rule"></span>'
"</div>"
)
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' <span class="tpb-sin-source">· {e(source)}</span>'
sin_blocks.append(
'<div class="tpb-sin-row">'
f'<div class="tpb-sin-n">{e(_ROMAN[i])}.</div>'
'<div class="tpb-sin-body">'
f'<p class="tpb-sin-title">{e(str(sin.get("title") or ""))}</p>'
f'<span class="tpb-sin-meta">{meta_html}</span>'
"</div></div>"
)
sins_html = "".join(sin_blocks)
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Trace Personality Bulletin</title>
{_FONTS_LINK}
<style>
html, body {{ margin: 0; padding: 0; background: {_SURFACE};
font-family: 'Source Sans 3', -apple-system, system-ui, sans-serif;
color: {_INK}; overflow: hidden; }}
* {{ box-sizing: border-box; }}
/* The card — fixed 1080×1440 logical size, scaled to fit the iframe via transform.
The wrap fills the iframe so there is no dark bleed below the stage. */
.tpb-wrap {{
width: 100vw;
height: 100vh;
display: flex;
align-items: flex-start;
justify-content: center;
background: {_SURFACE};
}}
.tpb-stage {{
width: 100vw;
height: 100vh;
position: relative;
overflow: hidden;
}}
.tpb-card {{
position: absolute;
top: 0; left: 0;
width: 1080px;
height: 1440px;
background: {_SURFACE};
color: {_INK};
box-shadow: 0 24px 80px rgba(0,0,0,0.45), 0 8px 24px rgba(0,0,0,0.30);
border-radius: 8px;
transform-origin: top left;
overflow: hidden;
isolation: isolate;
}}
.tpb-stage[data-scaled] .tpb-card {{
transform: scale(var(--tpb-scale, 1));
}}
/* Grain texture */
.tpb-grain {{
position: absolute; inset: 0;
background-image: url('{_GRAIN_URL}');
background-size: 700px;
opacity: 0.22;
mix-blend-mode: multiply;
pointer-events: none;
z-index: 1;
}}
/* Frame */
.tpb-frame-outer {{
position: absolute; inset: 22px;
border: 2px solid {_INK};
pointer-events: none;
z-index: 2;
}}
.tpb-frame-inner {{
position: absolute; inset: 30px;
border: 1px solid {_INK};
pointer-events: none;
z-index: 2;
}}
.tpb-corner {{
position: absolute;
width: 24px; height: 24px;
background: {_SURFACE};
color: {_INK};
display: flex; align-items: center; justify-content: center;
font-size: 16px; font-weight: 700;
z-index: 3;
}}
/* Content */
.tpb-content {{
position: absolute;
top: 42px; bottom: 42px;
left: 64px; right: 64px;
display: flex;
flex-direction: column;
z-index: 3;
}}
.tpb-masthead {{ text-align: center; color: {_INK}; }}
.tpb-masthead-tiny {{
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
letter-spacing: 0.34em;
text-transform: uppercase;
color: {_INK_SOFT};
margin-bottom: 8px;
}}
.tpb-masthead-big {{
font-family: 'Alfa Slab One', serif;
font-weight: 400;
font-size: 36px;
line-height: 1;
letter-spacing: 0.04em;
color: {_INK};
text-transform: uppercase;
margin: 0;
}}
.tpb-intro {{
margin-top: 18px;
text-align: center;
font-style: italic;
font-size: 15px;
color: {_INK_SOFT};
}}
.tpb-user-stamp {{
margin-top: 8px; display: flex; justify-content: center;
}}
.tpb-user-stamp-inner {{
font-family: 'IBM Plex Mono', monospace;
font-size: 15px;
font-weight: 600;
color: {_INK};
padding: 6px 18px;
border: 1.5px solid {_INK};
border-radius: 4px;
letter-spacing: 0.06em;
}}
/* Archetype */
.tpb-archetype {{
margin-top: 14px;
position: relative;
text-align: center;
padding: 12px 0 8px;
}}
.tpb-sunburst {{
position: absolute;
inset: -24px;
z-index: -1;
display: flex;
justify-content: center;
align-items: center;
}}
.tpb-eyebrow {{
font-family: 'IBM Plex Mono', monospace;
font-size: 13px;
letter-spacing: 0.32em;
text-transform: uppercase;
color: {_ACCENT};
font-weight: 600;
margin-bottom: 14px;
}}
.tpb-arch-title {{
font-family: 'Alfa Slab One', serif;
font-weight: 400;
font-size: 88px;
line-height: 0.92;
letter-spacing: -0.005em;
color: {_ACCENT};
margin: 0;
text-shadow: 4px 4px 0 {_INK}, -2px -2px 0 {_ACCENT2};
}}
/* Tagline */
.tpb-tagline {{
margin: 20px auto 0;
text-align: center;
font-style: italic;
font-size: 21px;
line-height: 1.4;
font-weight: 500;
color: {_INK};
max-width: 820px;
}}
/* Sins */
.tpb-sins-head {{
margin-top: 24px;
text-align: center;
font-family: 'Alfa Slab One', serif;
font-weight: 400;
font-size: 24px;
letter-spacing: 0.18em;
color: {_INK};
text-transform: uppercase;
}}
.tpb-sins-block {{
margin-top: 12px;
background: {_SINS_BG};
color: {_SINS_INK};
border-radius: 4px;
padding: 18px 30px;
position: relative;
overflow: hidden;
box-shadow: 5px 5px 0 {_INK};
}}
.tpb-sins-halftone {{
position: absolute; inset: 0;
background-image: radial-gradient({_SINS_DOTS} 1.4px, transparent 1.6px);
background-size: 14px 14px;
pointer-events: none;
opacity: 0.7;
}}
.tpb-sin-row {{
display: grid;
grid-template-columns: 60px 1fr;
gap: 18px;
align-items: baseline;
padding: 4px 0;
position: relative;
}}
.tpb-sin-n {{
font-style: italic;
font-weight: 500;
font-size: 26px;
line-height: 0.9;
color: {_SINS_ACCENT};
text-align: right;
letter-spacing: 0.02em;
}}
.tpb-sin-body {{ display: flex; flex-direction: column; gap: 4px; }}
.tpb-sin-title {{
font-size: 19px;
line-height: 1.25;
font-weight: 600;
color: {_SINS_INK};
margin: 0;
}}
.tpb-sin-meta {{
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
font-weight: 500;
color: {_SINS_INK};
opacity: 0.85;
letter-spacing: 0.04em;
font-style: italic;
}}
.tpb-sin-source {{
font-style: normal;
font-weight: 400;
opacity: 0.6;
letter-spacing: 0.06em;
margin-left: 2px;
}}
.tpb-sin-divider {{
display: flex; align-items: center; gap: 12px;
color: {_SINS_INK};
opacity: 0.5;
margin: 6px 28px;
}}
.tpb-rule {{ flex: 1; height: 1px; background: currentColor; }}
.tpb-rule-glyph {{
font-size: 13px; letter-spacing: 0.4em; white-space: nowrap;
}}
/* Horoscope */
.tpb-horo-head {{
margin-top: 44px;
text-align: center;
font-family: 'Alfa Slab One', serif;
font-weight: 400;
font-size: 22px;
letter-spacing: 0.18em;
color: {_INK};
text-transform: uppercase;
}}
.tpb-horo {{
margin-top: 14px;
padding: 0 8px;
}}
.tpb-dropcap {{
font-family: 'Alfa Slab One', serif;
font-weight: 400;
font-size: 74px;
line-height: 0.85;
color: {_ACCENT};
float: left;
margin-right: 12px;
margin-top: 4px;
text-shadow: 3px 3px 0 {_ACCENT2};
}}
.tpb-horo-body {{
font-size: 19px;
line-height: 1.45;
font-weight: 500;
color: {_INK};
margin: 0;
text-align: justify;
}}
/* Signoff */
.tpb-signoff {{
margin-top: auto;
padding-top: 14px;
display: grid;
grid-template-columns: 100px 1fr;
gap: 20px;
align-items: center;
}}
.tpb-stamp {{
width: 100px; height: 100px;
transform: rotate(-9deg);
filter: drop-shadow(2px 2px 0 rgba(0,0,0,0.05));
}}
.tpb-stamp img {{ width: 100%; height: 100%; object-fit: contain; display: block; }}
.tpb-sign-right {{ display: flex; flex-direction: column; gap: 6px; }}
.tpb-sign-title {{
font-family: 'IBM Plex Mono', monospace;
font-size: 10px;
letter-spacing: 0.28em;
text-transform: uppercase;
color: {_INK_SOFT};
}}
.tpb-sign-script {{
font-style: italic;
font-weight: 500;
font-size: 34px;
line-height: 1;
color: {_INK};
letter-spacing: -0.01em;
transform: skew(-6deg);
transform-origin: left center;
display: inline-block;
}}
.tpb-sign-line {{
margin-top: 6px;
height: 1px;
background: {_INK};
width: 70%;
}}
.tpb-sign-meta {{
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
letter-spacing: 0.12em;
color: {_INK_SOFT};
display: flex;
flex-wrap: wrap;
gap: 10px;
}}
</style>
</head>
<body>
<div class="tpb-wrap">
<div class="tpb-stage" id="tpb-stage">
<div class="tpb-card" id="tpb-card">
<div class="tpb-grain"></div>
<div class="tpb-frame-outer"></div>
<div class="tpb-frame-inner"></div>
<div class="tpb-corner" style="top:14px;left:14px;">✺</div>
<div class="tpb-corner" style="top:14px;right:14px;">✺</div>
<div class="tpb-corner" style="bottom:14px;left:14px;">✺</div>
<div class="tpb-corner" style="bottom:14px;right:14px;">✺</div>
<div class="tpb-content">
<div class="tpb-masthead">
<div class="tpb-masthead-tiny">★ Presented by Hugging Face ★ Anno MMXXVI ★</div>
<h2 class="tpb-masthead-big">Trace Personality Bulletin</h2>
</div>
<div class="tpb-intro">This bulletin hereby certifies, after careful observation, that the operator behind the handle</div>
<div class="tpb-user-stamp"><div class="tpb-user-stamp-inner">@{user}</div></div>
<div class="tpb-archetype">
<div class="tpb-sunburst">{_sunburst_svg()}</div>
<div class="tpb-eyebrow">is</div>
<h1 class="tpb-arch-title">{arch1}<br>{arch2}</h1>
</div>
<p class="tpb-tagline">{tagline}</p>
<div class="tpb-sins-head">✺ Three Mortal Sins ✺</div>
<div class="tpb-sins-block">
<div class="tpb-sins-halftone"></div>
{sins_html}
</div>
<div class="tpb-horo-head">✺ {headline} ✺</div>
<div class="tpb-horo">
<p class="tpb-horo-body">
<span class="tpb-dropcap">{drop_cap}</span>{body_rest}
</p>
</div>
<div class="tpb-signoff">
<div class="tpb-stamp"><img src="{_SEAL_URL}" alt="Hugging Face Roastery seal"/></div>
<div class="tpb-sign-right">
<div class="tpb-sign-title">Signed —</div>
<div class="tpb-sign-script">Hugging Face Roastery</div>
<div class="tpb-sign-line"></div>
<div class="tpb-sign-meta">
<span>Dated · {generated}</span><span>·</span>
<span>{turns:,} turns analysed</span><span>·</span>
<span>{dataset}</span><span>·</span>
<span>{serial}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
<script>
(function() {{
function fit() {{
var stage = document.getElementById('tpb-stage');
if (!stage) return;
var card = stage.querySelector('.tpb-card');
if (!card) return;
var s = stage.clientWidth / 1080;
card.style.transform = 'scale(' + s + ')';
stage.style.height = (1440 * s) + 'px';
stage.setAttribute('data-scaled', '1');
}}
window.addEventListener('resize', fit);
// Run once now and again on the next frame to catch late-loaded fonts.
fit();
requestAnimationFrame(fit);
setTimeout(fit, 200);
window.__tpbSave = async function() {{
if (typeof html2canvas === 'undefined') {{ return; }}
var card = document.getElementById('tpb-card');
if (!card) return;
// Clone the card off-stage at full 1080×1440 so the screenshot gets the
// whole layout regardless of how small the iframe is being displayed.
var clone = card.cloneNode(true);
clone.id = 'tpb-card-clone';
Object.assign(clone.style, {{
position: 'fixed', top: '0px', left: '0px',
width: '1080px', height: '1440px',
transform: 'none', margin: '0',
boxShadow: 'none', borderRadius: '0',
zIndex: '999999',
}});
// Park it under a sized container so html2canvas's window simulation
// doesn't fold the page width down to the iframe width.
var holder = document.createElement('div');
Object.assign(holder.style, {{
position: 'fixed', top: '0px', left: '0px',
width: '1080px', height: '1440px',
overflow: 'hidden',
zIndex: '999998',
background: '{_SURFACE}',
}});
holder.appendChild(clone);
document.body.appendChild(holder);
// Let fonts/layout settle.
await new Promise(function (r) {{ requestAnimationFrame(function () {{ requestAnimationFrame(r); }}); }});
try {{
var canvas = await html2canvas(clone, {{
width: 1080, height: 1440,
windowWidth: 1080, windowHeight: 1440,
backgroundColor: '{_SURFACE}',
useCORS: true, scale: 2, logging: false,
}});
var link = document.createElement('a');
link.download = 'trace-personality-bulletin-{serial}.png';
link.href = canvas.toDataURL('image/png');
link.click();
}} finally {{
document.body.removeChild(holder);
}}
}};
}})();
</script>
</body>
</html>
""".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("&", "&amp;").replace('"', "&quot;")
# 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"""
<div style="display:flex;flex-direction:column;gap:12px;align-items:center;width:100%;">
<button type="button" onclick="(function(){{
var f = document.getElementById('{frame_id}');
if (f && f.contentWindow && f.contentWindow.__tpbSave) {{
f.contentWindow.__tpbSave();
}}
}})()" style="
background:{_INK};color:{_SURFACE};border:none;
padding:10px 20px;border-radius:6px;cursor:pointer;
font-family:'IBM Plex Mono', ui-monospace, monospace;
font-size:12px;letter-spacing:0.14em;text-transform:uppercase;
box-shadow:0 4px 12px rgba(0,0,0,0.18);
">📸 Save as PNG</button>
<iframe id="{frame_id}" srcdoc="{escaped}"
style="width:100%;max-width:880px;aspect-ratio:1080/1440;border:none;
display:block;background:{_SURFACE};border-radius:8px;
box-shadow:0 16px 40px rgba(0,0,0,0.35);"
sandbox="allow-scripts allow-downloads allow-same-origin"></iframe>
</div>
""".strip()
def empty_bulletin_html(message: str = "Awaiting bulletin…") -> str:
"""Lightweight placeholder shown before a report is generated."""
return f"""
<div style="display:flex;justify-content:center;align-items:center;
min-height:360px;background:{_SURFACE};border-radius:8px;
font-family:'Source Sans 3', sans-serif;color:{_INK_SOFT};
font-style:italic;font-size:18px;padding:40px;text-align:center;">
{html_mod.escape(message)}
</div>
""".strip()