| """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 = " " |
|
|
| |
| _INK = "#353E60" |
| _INK_SOFT = "rgba(53,62,96,0.7)" |
| _SURFACE = "#FFF8DE" |
| _ACCENT = "#00704A" |
| _ACCENT2 = "#FF9D00" |
| _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" |
| _YELLOW = "#FFD21E" |
|
|
| |
| _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 "")) |
|
|
| |
| 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() |
| |
| |
| 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) |
| |
| |
| |
| escaped = doc.replace("&", "&").replace('"', """) |
| |
| 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() |
|
|