"""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' {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'
    {"".join(buttons)}
    ' def _glass_shell( extra_class: str, messages: list[str], *, head: str = "", actions: list[dict] | None = None, ) -> str: shell_class = "plea-shell" if head else "" return f"""
    {head}
    {"".join(messages)}{reply_chips(actions)}
    """ # 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'' 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'' 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 ( '
    ' 'Case File · what feeds Suspicion' f'
    {"".join(rows)}
    ' "
    " ) def judgement_card( state: TrialState, *, revised: bool, revision_marker: str = "", closing_note: str = "", meter_deltas: dict[str, float] | None = None, ) -> str: v = state.verdict stamp = "" if revised else ( f'
    {escape(verdict_stamp_word(v.band))}
    ' ) 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"
    1. {escape(r)}
    2. " 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}
    {marker} {docket}
    The court finds

    {escape(state.verdict_title)}

    {escape(data.confidence_phrase(v.band, v.confidence))}
    {meters} {case_file} {reasons} {flavor}
    {escape(state.sentence or data.FALLBACK_SENTENCE)}
    {note}
    """ def record_card(state: TrialState, *, completed: bool) -> str: v = state.verdict served = ( 'Sentence served. The court grudgingly approves.' if completed else "" ) return f"""
    Tiny Court Certified

    Court Record

    Case{escape(state.case_title)}
    Defendant{escape(state.accused)}
    Charge{escape(state.charge)}
    Verdict{escape(state.verdict_title)} — {escape(data.confidence_phrase(v.band, v.confidence))}
    Best Quote{escape(state.best_quote or data.FALLBACK_BEST_QUOTE)}
    {meters_strip(state, animate=False)} {case_file_strip(state, animate=False)}
    {escape(state.sentence or data.FALLBACK_SENTENCE)}
    {served}
    """ # --- Chips + session line --------------------------------------------------- def chips(attachments: list[dict], session_path: str | None) -> str: chip_html = "" if attachments: chip_html = '
    ' + "".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 ( '
    {cards}{style}
    ' ) # --- Shareable plain text (reused by Copy / Share) -------------------------- def share_text(state: TrialState) -> str: v = state.verdict return ( "⚖ Tiny Court of Everyday Crimes\n" f"Case: {state.case_title}\n" f"Charge: {state.charge}\n" f"Verdict: {state.verdict_title} (Confidence: {v.confidence}%)\n" f"Sentence: {state.sentence}\n" f'Best Quote: "{state.best_quote}"' )