"""The winner celebration — a one-shot, cheerful "champion" moment for the Show. This is the *modular* half of the verdict surface (ADR-0029). The verdict banner (``render.meters.render_verdict``) is the sober ruling; this is the confetti. It is a pure HTML string, gated on one thing: did the run declare a winner? A ``none``-kind scenario (Wood, Oracle) never sets ``verdict.winner_label``, so ``render_winner`` returns ``""`` and adds nothing to the page — the celebration is *opt-in by data*, not bolted onto every scenario. The card names the winning **bot**: for an agent win it shows the mind's archetype and the model that actually ran; for a team win it lines up the roster as chips and names the side it dethroned. It tints itself with the winner's own hue (``hsl(var(--win-hue) …)``) so the champion of a teal mind glows teal. No Gradio import — the same string feeds ``gr.HTML`` today and a future JSON endpoint. """ from __future__ import annotations import html # The phosphor palette, in confetti form — cycles through the theme accents so the burst # reads as "our CRT theater is celebrating", not a generic party popper. _CONFETTI_COLORS = ("var(--lime)", "var(--cyan)", "var(--amber)", "var(--violet)", "var(--coral)") _CONFETTI_COUNT = 30 def _pretty(slug: str) -> str: """``spy-nil`` → ``Spy Nil`` — the same shape ``_winner_label`` uses for the ribbon.""" return slug.replace("-", " ").replace("_", " ").title() def _confetti() -> str: """A finite burst of phosphor confetti. Each bit falls exactly once (``animation-iteration-count: 1`` + ``forwards`` in the CSS), so the sky clears after a few seconds and the champion card is left glowing on its own — cheerful, not a perpetual distraction. Geometry is index-derived (deterministic) so the burst is stable across re-renders and trivial to snapshot in tests. """ bits: list[str] = [] for i in range(_CONFETTI_COUNT): color = _CONFETTI_COLORS[i % len(_CONFETTI_COLORS)] left = (i * 37) % 100 # spread across the width without an even, mechanical comb delay = round((i % 10) * 0.13, 2) # stagger the fall so it rains, not drops as a sheet dur = round(2.6 + (i % 6) * 0.32, 2) drift = ((i * 53) % 140) - 70 # -70…70px lateral sway as it falls rot = (i * 47) % 360 klass = "wf-bit round" if i % 3 == 0 else "wf-bit" style = ( f"left:{left}%;background:{color};" f"animation-delay:{delay}s;animation-duration:{dur}s;" f"--wf-drift:{drift}px;--wf-rot:{rot}deg" ) bits.append(f'') return f'
' def _chip(text: str, hue: int | None = None) -> str: # A roster chip wears its own member's hue so a teammate's name matches the colour # they wore on stage; hue-less chips (e.g. the model chip) keep the winner's tint. style = f' style="--ch:{int(hue)}"' if hue is not None else "" return f'{html.escape(text)}' def render_winner(vm: dict) -> str: """The champion celebration — ``""`` until (and unless) the run declares a winner. Reads ``vm["verdict"]`` (the same dict the banner uses) plus ``vm["cast"]`` / ``vm["teams"]`` to name the winning bot. Renders nothing when ``winner_label`` is absent, which is every ``none``-kind scenario and any legacy verdict — that absence is the modularity contract, so this can be composed into every Show without touching the scenarios that have no winner to crown. """ verdict = vm.get("verdict") or {} label = verdict.get("winner_label") if not label: return "" winner = verdict.get("winner") kind = verdict.get("winner_kind") correct = verdict.get("correct") cast = vm.get("cast") or [] teams = vm.get("teams") or {} by_id = {c.get("id"): c for c in cast} # Default phosphor-lime hue, overridden by the winning bot's own hue so the card's glow # matches the mind on the stage that just won. hue = 95 sub = "" roster = "" if kind == "team": members = [by_id[m] for m in (teams.get(winner) or []) if m in by_id] if members: hue = int(members[0].get("hue", hue)) chips = "".join(_chip(str(m.get("name", "")), m.get("hue")) for m in members) roster = f'