Spaces:
Sleeping
Sleeping
| """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("&", "&").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""" | |
| <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() | |