"""HTML renderers for the single-page court wizard.
These build the wizard's markup (smoked-glass chat shells + opaque "paper"
ruling cards) from the engine's ``TrialState``. The Gradio app
(``tinycourt/app.py``) owns flow and the real composer/buttons; this module only
builds the inner HTML for the ``gr.HTML`` surfaces. All dynamic text is escaped —
the comedy is user- and model-supplied.
The visual system (classes, fonts, background crops) lives in ``static/courtroom.css``; this
file must emit the matching class names (``message-shell``, ``court-message``,
``ruling-surface``, ``verdict-stamp``, ``seal`` …).
"""
from __future__ import annotations
from html import escape
from . import data
from .trial import COURT_VOICES, GUILTY, LESSER, NOT_GUILTY, TrialState
# --- Inline icons (the courtroom role/action icon() map) -------------------
_ICONS = {
"file": '',
"witness": '',
"evidence": '',
"hammer": '',
"stamp": '',
"mic": '',
}
def icon(name: str) -> str:
return _ICONS.get(name, _ICONS["file"])
def _avatar(name: str) -> str:
return f'{icon(name)}'
# Which avatar each role speaks with.
_ROLE_AVATAR = {
"Court Clerk": "file",
"Complaint": "witness",
"You": "witness",
"Accused": "witness",
"Defense": "witness",
"Prosecutor": "evidence",
"Witness": "evidence",
"Evidence": "evidence",
"Evidence Tray": "evidence",
"Judge": "hammer",
}
# --- Verdict helpers --------------------------------------------------------
_STAMP_WORD = {NOT_GUILTY: "Not Guilty", LESSER: "Lesser Pettiness", GUILTY: "Guilty"}
def verdict_stamp_word(band: str) -> str:
return _STAMP_WORD.get(band, "Guilty")
# --- Phase stepper — a dynamic timeline of what happened --------------------
# The stepper is a *record of this trial*, not a fixed playlist
# (docs/full-trial-conversation-flow.md §7, design B). The mandatory anchors
# (Case · Verdict · Sentence) are always present; an optional scene's pip
# (Evidence, Witness, Twist, Appeal) is inserted by the caller only once that
# scene has actually happened. So a session that never called a witness simply
# has no Witness pip — the timeline never implies a skipped step.
#
# The caller (`app._timeline`) owns *which* steps are present and their state;
# this renderer just lays out the ``(label, status)`` list it is handed, where
# ``status`` is "active" (the live focus), "done" (already passed), or "" (a
# pending anchor such as the terminal Verdict before it is reached).
def stepper(steps: list[tuple[str, str]]) -> str:
if not steps:
return ""
items = []
active_index = 0
for i, (label, status) in enumerate(steps):
if status == "active":
active_index = i
aria = ' aria-current="step"' if status == "active" else ""
items.append(
f'
'
f'{i + 1} {escape(label)}
'
)
return (
'"
)
# --- Glass chat bubbles -----------------------------------------------------
def _side(who: str) -> str:
"""Bubble side for a chat event: the court's voices render court-side,
every human speaking label (You, Complaint, Accused, Witness…) user-side."""
return "" if who in COURT_VOICES else "user"
def court_message(role: str, text: str, *, kind: str = "") -> str:
avatar = _ROLE_AVATAR.get(role, "file")
return f"""
{_avatar(avatar)}
{escape(role)}
{escape(text)}
"""
def reply_chips(actions: list[dict] | None) -> str:
"""Render the phase's actions as smoked-glass reply chips that sit under the
last court message. Inert HTML — each chip carries a ``data-tc-action`` id that
a document-level click listener forwards to the Python dispatcher (see the
inbound bridge in :mod:`tinycourt.app`). The primary action is emphasized."""
if not actions:
return ""
buttons = []
for a in actions:
cls = "reply-chip primary" if a.get("kind") == "primary" else "reply-chip"
buttons.append(
f''
)
return f'
"""
# Optional scenes are separated in the one growing transcript by a light divider
# rather than by clearing the screen (docs/full-trial-conversation-flow.md §4).
# The label is emitted only when that scene actually occurs — the divider is
# driven by the per-event ``scene`` tag, so a session with no twist shows no
# "Twist!" rule. ``case`` is the opening scene and needs no divider.
SCENE_LABELS = {
"opening": "Opening Statements",
"evidence": "Evidence",
"witness": "The Witness",
"twist": "Twist!",
"objection": "Objection!",
"appeal": "Appeal",
}
def scene_divider(label: str) -> str:
return f'
{escape(label)}
'
def glass_trial(
state: TrialState,
events: list[dict] | None = None,
*,
actions: list[dict] | None = None,
focus: str = "case",
) -> str:
"""The one continuous pre-verdict (and appeal) transcript.
Renders the *entire* ordered ``events`` list as a single bubble stream — no
``scene`` filter, no per-call cap (the shell scrolls internally and is
bottom-anchored). A light scene divider is inserted whenever the ``scene``
tag changes to a labelled scene, so the flow stays legible without ever
clearing earlier messages (docs/full-trial-conversation-flow.md §2, §4). The
``focus`` only tints the shell (and drives the composer placeholder upstream);
the history behind it is always whole.
"""
msgs: list[str] = []
prev_scene: str | None = None
for ev in events or []:
scene = ev.get("scene", "case")
if scene != prev_scene and scene in SCENE_LABELS:
msgs.append(scene_divider(SCENE_LABELS[scene]))
who = ev.get("who", "Court Clerk")
msgs.append(court_message(who, ev.get("text", ""), kind=_side(who)))
prev_scene = scene
shell_class = "complaint-surface" if focus == "case" else "evidence-surface"
return _glass_shell(shell_class, msgs, actions=actions)
# --- Paper ruling / record cards -------------------------------------------
def _docket_strip(state: TrialState) -> str:
return f"""
Case{escape(state.case_title)}
Defendant{escape(state.accused)}
Charge{escape(state.charge)}
"""
def _reason_items(state: TrialState) -> list[str]:
"""The verdict card's 2–3 short reasons (design-spec §10.5): the closing
call's own reasons when it supplied them, else scraped transcript lines."""
if state.reasons:
return state.reasons[:3]
out = []
for card in reversed(state.transcript):
if card.role in ("Prosecutor", "Defense", "Witness"):
first = card.text.split(". ")[0].strip().rstrip(".")
if first and first not in out:
out.append(first + ".")
if len(out) >= 3:
break
out.reverse()
if not out:
out = [escape(state.charge or "A tiny crime of suspicious convenience.")]
return out
# --- Court meters strip -----------------------------------------------------
# The verdict engine (trial.py) tracks the court meters; the review flagged that they
# were computed but never shown ("make consequence visible"). This renders them
# as labelled bars on the ruling/record cards, with an optional signed delta
# badge so a plea's effect on the needle is legible, not just asserted in prose.
# Compact single-word labels so all six fit one row; the full meter name + what
# it does lives in the title tooltip. Order groups the two guilt drivers first
# (Suspicion = vibes, Evidence = proof), then severity, then the non-guilt axes.
_METERS = [
("suspicion", "Suspicion", "Suspicion — how guilty the accused looks (the vibes)"),
("evidence", "Evidence", "Evidence Weight — how much can actually be proven"),
("severity", "Severity", "Petty Severity — how grave the tiny crime is"),
("dignity", "Dignity", "Courtroom Dignity — how composed the court remains"),
("mercy", "Mercy", "Mercy — how softly the hammer falls"),
("patience", "Patience", "His Honor's Last Nerve — how much chatter the court will still abide"),
]
# The Case File legs that compose into Suspicion in a Full Trial (Config C).
# Shown as a second, captioned strip beneath the main meters so the headline
# Suspicion bar is visibly the sum of its parts — without cluttering Quick Trial.
_CASE_FILE = [
("means", "Means", "Could they have done it — the how"),
("motive", "Motive", "Did they want to — the why"),
("opportunity", "Opportunity", "Were they positioned to — the when and where"),
]
def _meter_value(state: TrialState, key: str) -> float:
m = state.meters
raw = {
"suspicion": m.suspicion,
"evidence": m.evidence_weight,
"severity": m.petty_severity,
"dignity": m.courtroom_dignity,
"mercy": m.mercy,
"patience": m.patience,
"means": m.means,
"motive": m.motive,
"opportunity": m.opportunity,
}[key]
return max(0.0, min(100.0, raw))
def _delta_badge(delta: float | None) -> str:
"""A ▲/▼ badge for a meter that moved (e.g. after a plea). Sub-1 moves are
rounding noise and render nothing. The arrow is decorative (aria-hidden); the
signed amount is also exposed as SR-only text so the movement isn't
sighted-only."""
if not delta or abs(delta) < 1.0:
return ""
arrow = "▲" if delta > 0 else "▼"
cls = "up" if delta > 0 else "down"
amount = abs(round(delta))
word = "up" if delta > 0 else "down"
return (
f'{arrow}{amount}'
f' ({word} {amount})'
)
def _meter_row(state: TrialState, key: str, label: str, hint: str, deltas: dict[str, float] | None) -> str:
val = _meter_value(state, key)
badge = _delta_badge((deltas or {}).get(key))
return (
f'
'
f'
{escape(label)}'
f'{badge}{val:.0f}
'
f'
'
"
"
)
def meters_strip(
state: TrialState, *, deltas: dict[str, float] | None = None, animate: bool = True
) -> str:
"""The court meters as labelled bars. ``deltas`` adds ▲/▼ badges (only
meaningful where there's a before→after, i.e. the revised card). ``animate``
plays the settle-in fill on mount — on for the verdict *reveal*, off for the
frozen shareable record so a screenshot never catches bars mid-grow."""
rows = [_meter_row(state, key, label, hint, deltas) for key, label, hint in _METERS]
cls = "meters" if animate else "meters meters--static"
return f'
{"".join(rows)}
'
def case_file_strip(
state: TrialState, *, deltas: dict[str, float] | None = None, animate: bool = True
) -> str:
"""The Full Trial Case File: the three legs (Means/Motive/Opportunity) that
compose into Suspicion (Config C). Rendered as a captioned sub-strip beneath
the main meters so the headline Suspicion bar reads as the sum of its parts.
Returns "" for a Quick Trial, which never shows the dense decomposition."""
if not state.is_full:
return ""
rows = [_meter_row(state, key, label, hint, deltas) for key, label, hint in _CASE_FILE]
static = "" if animate else " meters--static"
return (
'
'
)
marker = ""
if revised and revision_marker:
marker = (
'
Revised ruling'
f'{escape(revision_marker)}
'
)
docket = "" if revised else _docket_strip(state)
reasons = "" if revised else (
''
+ "".join(f"
{escape(r)}
" for r in _reason_items(state))
+ ""
)
# The flavor line is this case's own docket data (severity label + court
# mood, both written by the CASE_OPEN call), not fixed copy.
flavor = ""
if not revised and (state.severity_label or state.court_mood):
bits = [b for b in (
f"Filed under: {state.severity_label}" if state.severity_label else "",
f"Court mood: {state.court_mood}" if state.court_mood else "",
) if b]
flavor = f'
{escape(". ".join(bits))}.
'
note = f'
{escape(closing_note)}
' if closing_note else ""
# Show the meters on every ruling, with delta badges for the movement the
# user caused: on the first judgement that's their evidence/objections since
# charges were filed; on the revised card it's the plea's effect.
meters = meters_strip(state, deltas=meter_deltas)
case_file = case_file_strip(state, deltas=meter_deltas)
return f"""
{stamp}
' + "".join(
f'{escape(a.get("label", "exhibit"))}' for a in attachments
) + "
"
session = ""
if session_path:
session = (
'
Court session created: '
f"{escape(session_path)} — use Invite to copy.
"
)
return chip_html + session
# --- Join screen ------------------------------------------------------------
def join_summary(session_path: str | None, case_title: str = "") -> str:
where = escape(session_path or "/court/pending")
title = f" The matter on the docket: {escape(case_title)}." if case_title else ""
return f"""
You have been summoned.
Court session {where} is already in progress.{title}
Choose your level of involvement.
"""
# --- Landing hero docket (a single, rotating faux court record) --------------
# The landing front-loads a strong court record (remaining-work T4) so a first-
# time visitor reads the joke + output format at a glance. It is ONE card that
# rotates through several cases: every docket is stacked into the same grid cell
# and crossfaded with pure CSS, phased apart by a negative animation-delay keyed
# off each card's --k (see the .landing-hero-rotator block in courtroom.css). The
# only piece CSS can't express is the count-dependent crossfade timeline (keyframe
# %s can't be a var()), so it is generated here from the same data and inlined.
def _hero_card(ex: data.LandingExample, k: int) -> str:
"""One faux court record; `--k` is its slot index in the CSS rotation."""
return f"""
{escape(ex.stamp)}
Now on the docket{escape(ex.case_id)}
“{escape(ex.complaint)}”
{escape(ex.charge)}
Verdict{escape(ex.judgement)}
Sentence{escape(ex.sentence)}
"""
def _rotate_keyframes(n: int, *, fade_pct: float = 6.0) -> str:
"""The count-dependent crossfade timeline (shared `tc-ex-rotate`).
Each card is opaque for one 1/n slice of the shared cycle and hidden the
rest; siblings are phased apart by a negative ``animation-delay`` keyed off
``--k`` (in courtroom.css), so exactly one shows at a time. The visible-window
boundary is the only number that depends on n; emit it here. One card needs no
rotation."""
if n <= 1:
return ""
window = 100.0 / n
fade = min(fade_pct, window / 3)
pct = lambda v: f"{round(v, 2):g}%" # noqa: E731
return (
"@keyframes tc-ex-rotate{"
f"0%{{opacity:0}}{pct(fade)}{{opacity:1}}"
f"{pct(window - fade)}{{opacity:1}}{pct(window)}{{opacity:0}}"
"100%{opacity:0}}"
)
def landing_hero(dockets: list[data.LandingExample] | None = None) -> str:
"""The pinned hero docket: one faux court record that rotates through several
cases (case → charge → verdict + stamp → sentence). With a single docket it is
static; with several it crossfades."""
dockets = dockets or data.LANDING_DOCKETS
cards = "".join(_hero_card(ex, k) for k, ex in enumerate(dockets))
keyframes = _rotate_keyframes(len(dockets))
style = f"" if keyframes else ""
return (
'