"""MindCard renderer — the say-vs-think flip card (the whole point of the Show). A pure HTML-string port of the prototype's ``MindCard`` component (``ui/raw/show.jsx``): a flippable card whose front shows the avatar, name, archetype, tier dot, mood label, and two bubbles ("said aloud" / "actually thinking"). When the mind-reader is off the thought bubble is sealed; when the agent is panicking the secret "leaks"; on a verdict the card flips to a back face revealing the truth. No Gradio import — the returned string drops into a ``gr.HTML`` block, and Unit 1's CSS animates the emitted classes. """ from __future__ import annotations import html from src.ui.fishbowl.adapter import TIER_COLOR, agent_color, agent_color_dim, mood_color from src.ui.fishbowl.render.avatar import render_avatar __all__ = ["render_mindcard"] # small "eye off" glyph for the sealed-thought placeholder (monoline, matches the proto) _EYE_OFF = ( '' ) _MUTED = '{text}' def _avatar_size(variant: str) -> int: """The prototype's per-variant avatar size (row=40, stage/split=50).""" return 40 if variant == "row" else 50 def render_mindcard( card: dict, *, mind_reader: bool, flipped: bool = False, variant: str = "ring", secret: str | None = None, ) -> str: """Render one cast card (a ``view_model_at(...)["cast"][i]`` element) as a flip card. ``mind_reader`` gates the thought bubble (sealed placeholder when off). ``flipped`` mounts the verdict back face. ``variant`` selects the card flavour (``ring`` / ``stage`` / ``row`` / ``split``) used in the CSS class. ``secret`` overrides the back-face reveal text; by default the back is built from the role. """ hue = int(card.get("hue", 190)) col = agent_color(hue) dim = agent_color_dim(hue) mood = card.get("mood", "calm") speaking = bool(card.get("speaking")) spoke = bool(card.get("spoke")) classes = ["mind", f"mind-{variant}"] if speaking: classes.append("speaking") if mood == "panic": classes.append("rattled") if flipped: classes.append("flipped") cls = " ".join(classes) # the avatar wears the live mood while speaking or once it has spoken, else calm face_mood = mood if (speaking or spoke) else "calm" avatar = render_avatar(hue, face_mood, size=_avatar_size(variant), active=speaking) name = html.escape(str(card.get("name", ""))) archetype = html.escape(str(card.get("archetype", ""))) # The real model the cast member is running (ADR-0022 endpoint override), falling back # to the profile tier name when it routes purely by profile. model = html.escape(str(card.get("model") or card.get("model_profile", ""))) mood_label = html.escape(str(card.get("mood_label", ""))) mood_col = mood_color(mood) tier = card.get("tier", "mid") tier_color = TIER_COLOR.get(tier, "var(--cyan)") # The profile label (tiny/fast/balanced/strong) sits beside the model name as the # human-chosen tier; the concrete served id rides the tooltip so a long name stays short. profile = html.escape(str(card.get("model_profile", ""))) model_title = html.escape(str(card.get("model_id") or card.get("model") or profile)) mic = '' if speaking else "" # ── said-aloud bubble ──────────────────────────────────────────────────── said = card.get("said") said_body = html.escape(str(said)) if said else _MUTED.format(text="— hasn't spoken —") # ── thought bubble (sealed when the mind-reader is off) ────────────────── leak = " leak" if mood == "panic" else "" if mind_reader: thought = card.get("thought") thought_body = html.escape(str(thought)) if thought else _MUTED.format(text="— quiet in here —") thought_tag = "actually thinking" thought_inner = f'

{thought_body}

' thought_state = " open" else: thought_tag = "mind sealed" thought_inner = f'
{_EYE_OFF} flip “read their minds” to look inside
' thought_state = " sealed" front = ( '
' '
' f"{avatar}" '
' f'
{name}{mic}
' f'
{archetype}
' "
" f'
' '' f'{mood_label}' "
" "
" f'
' '' f'' f'{model}' "" f'{profile}' "
" '
' '
' 'said aloud' f"

{said_body}

" "
" f'
' f'{thought_tag}' f"{thought_inner}" "
" "
" "
" ) # ── back face (verdict reveal) — only mounted when flipped, so it never leaks ─ back = "" if flipped: if secret is not None: reveal_text = html.escape(str(secret)) else: role = html.escape(str(card.get("role", ""))) reveal_text = role or html.escape(str(card.get("said") or "")) back = ( '
' '
' '
the truth
' f'
{reveal_text}
' "
" ) return f'
{front}{back}
'