| """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 |
|
|
| |
|
|
| _ICONS = { |
| "file": '<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 3h7l4 4v14H7z"/><path d="M14 3v5h5"/><path d="M9 13h6M9 17h6"/></svg>', |
| "witness": '<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z"/><path d="M4 21a8 8 0 0 1 16 0"/></svg>', |
| "evidence": '<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16v13H4z"/><path d="M8 7V4h8v3"/><path d="M8 12h8M8 16h5"/></svg>', |
| "hammer": '<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m14 5 5 5"/><path d="m12 7 5 5"/><path d="m5 14 5 5"/><path d="m9 18 8-8"/><path d="m3 21 6-6"/></svg>', |
| "stamp": '<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 4h6v7l3 4v3H6v-3l3-4z"/><path d="M5 21h14"/></svg>', |
| "mic": '<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><path d="M12 19v3"/></svg>', |
| } |
|
|
|
|
| def icon(name: str) -> str: |
| return _ICONS.get(name, _ICONS["file"]) |
|
|
|
|
| def _avatar(name: str) -> str: |
| return f'<span class="avatar-icon">{icon(name)}</span>' |
|
|
|
|
| |
| _ROLE_AVATAR = { |
| "Court Clerk": "file", |
| "Complaint": "witness", |
| "You": "witness", |
| "Accused": "witness", |
| "Defense": "witness", |
| "Prosecutor": "evidence", |
| "Witness": "evidence", |
| "Evidence": "evidence", |
| "Evidence Tray": "evidence", |
| "Judge": "hammer", |
| } |
|
|
|
|
| |
|
|
| _STAMP_WORD = {NOT_GUILTY: "Not Guilty", LESSER: "Lesser Pettiness", GUILTY: "Guilty"} |
|
|
|
|
| def verdict_stamp_word(band: str) -> str: |
| return _STAMP_WORD.get(band, "Guilty") |
|
|
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| 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'<li class="step {status}"{aria}>' |
| f'<span aria-hidden="true">{i + 1}</span> {escape(label)}</li>' |
| ) |
| return ( |
| '<nav class="stepper" aria-label="Court progress">' |
| f'<ol class="stepper-list">{"".join(items)}</ol>' |
| f'<span class="step-progress" aria-hidden="true">Step {active_index + 1} of {len(steps)}</span>' |
| "</nav>" |
| ) |
|
|
|
|
| |
|
|
|
|
| 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""" |
| <div class="court-message {kind}"> |
| <div class="message-avatar">{_avatar(avatar)}</div> |
| <div class="message-bubble"> |
| <span class="message-label">{escape(role)}</span> |
| {escape(text)} |
| </div> |
| </div>""" |
|
|
|
|
| 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'<button type="button" class="{cls}" ' |
| f'data-tc-action="{escape(a.get("id", ""))}">{escape(a.get("label", ""))}</button>' |
| ) |
| return f'<div class="reply-actions">{"".join(buttons)}</div>' |
|
|
|
|
| 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""" |
| <article class="message-shell {extra_class} {shell_class}"> |
| {head} |
| <div class="message-list">{"".join(messages)}{reply_chips(actions)}</div> |
| </article>""" |
|
|
|
|
| |
| |
| |
| |
| |
| SCENE_LABELS = { |
| "opening": "Opening Statements", |
| "evidence": "Evidence", |
| "witness": "The Witness", |
| "twist": "Twist!", |
| "objection": "Objection!", |
| "appeal": "Appeal", |
| } |
|
|
|
|
| def scene_divider(label: str) -> str: |
| return f'<div class="scene-divider" role="separator"><span>{escape(label)}</span></div>' |
|
|
|
|
| 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) |
|
|
|
|
| |
|
|
|
|
| def _docket_strip(state: TrialState) -> str: |
| return f""" |
| <div class="docket-strip"> |
| <div class="docket-mini"><span>Case</span><strong>{escape(state.case_title)}</strong></div> |
| <div class="docket-mini"><span>Defendant</span><strong>{escape(state.accused)}</strong></div> |
| <div class="docket-mini"><span>Charge</span><strong>{escape(state.charge)}</strong></div> |
| </div>""" |
|
|
|
|
| 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 |
|
|
|
|
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| _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"), |
| ] |
|
|
|
|
| |
| |
| |
| _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'<span class="meter-delta {cls}"><span aria-hidden="true">{arrow}{amount}</span>' |
| f'<span class="sr-only"> ({word} {amount})</span></span>' |
| ) |
|
|
|
|
| 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'<div class="meter meter--{key}" title="{escape(hint)}">' |
| f'<div class="meter-top"><span class="meter-label">{escape(label)}</span>' |
| f'{badge}<span class="meter-value">{val:.0f}</span></div>' |
| f'<div class="meter-track"><span class="meter-fill" style="--fill:{val:.0f}%"></span></div>' |
| "</div>" |
| ) |
|
|
|
|
| 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'<div class="{cls}" role="group" aria-label="Court meters">{"".join(rows)}</div>' |
|
|
|
|
| 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 ( |
| '<div class="case-file">' |
| '<span class="case-file-caption">Case File · what feeds Suspicion</span>' |
| f'<div class="meters case-file-legs{static}" role="group" aria-label="Case file">{"".join(rows)}</div>' |
| "</div>" |
| ) |
|
|
|
|
| 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'<div class="verdict-stamp">{escape(verdict_stamp_word(v.band))}</div>' |
| ) |
| marker = "" |
| if revised and revision_marker: |
| marker = ( |
| '<div class="revision-banner"><strong>Revised ruling</strong>' |
| f'<span>{escape(revision_marker)}</span></div>' |
| ) |
| docket = "" if revised else _docket_strip(state) |
| reasons = "" if revised else ( |
| '<ol class="reason-list">' |
| + "".join(f"<li>{escape(r)}</li>" for r in _reason_items(state)) |
| + "</ol>" |
| ) |
| |
| |
| 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'<div class="judge-note flavor-note">{escape(". ".join(bits))}.</div>' |
| note = f'<div class="judge-note">{escape(closing_note)}</div>' if closing_note else "" |
| |
| |
| |
| meters = meters_strip(state, deltas=meter_deltas) |
| case_file = case_file_strip(state, deltas=meter_deltas) |
| return f""" |
| <article class="surface ruling-surface"> |
| {stamp} |
| <div class="surface-pad judgement-card"> |
| {marker} |
| {docket} |
| <div class="verdict-head"> |
| <span class="verdict-label">The court finds</span> |
| <h1 class="verdict-title">{escape(state.verdict_title)}</h1> |
| <span class="confidence">{escape(data.confidence_phrase(v.band, v.confidence))}</span> |
| </div> |
| {meters} |
| {case_file} |
| {reasons} |
| {flavor} |
| <div class="court-order">{escape(state.sentence or data.FALLBACK_SENTENCE)}</div> |
| {note} |
| </div> |
| </article>""" |
|
|
|
|
| def record_card(state: TrialState, *, completed: bool) -> str: |
| v = state.verdict |
| served = ( |
| '<span class="change-marker">Sentence served. The court grudgingly approves.</span>' |
| if completed |
| else "" |
| ) |
| return f""" |
| <article class="surface ruling-surface"> |
| <div class="surface-pad judgement-card"> |
| <div class="seal">Tiny Court Certified</div> |
| <h1 class="prompt">Court Record</h1> |
| <div class="record-grid"> |
| <div class="record-item"><span class="record-label">Case</span><span class="record-value">{escape(state.case_title)}</span></div> |
| <div class="record-item"><span class="record-label">Defendant</span><span class="record-value">{escape(state.accused)}</span></div> |
| <div class="record-item"><span class="record-label">Charge</span><span class="record-value">{escape(state.charge)}</span></div> |
| <div class="record-item"><span class="record-label">Verdict</span><span class="record-value">{escape(state.verdict_title)} — {escape(data.confidence_phrase(v.band, v.confidence))}</span></div> |
| <div class="record-item"><span class="record-label">Best Quote</span><span class="record-value">{escape(state.best_quote or data.FALLBACK_BEST_QUOTE)}</span></div> |
| </div> |
| {meters_strip(state, animate=False)} |
| {case_file_strip(state, animate=False)} |
| <div class="court-order">{escape(state.sentence or data.FALLBACK_SENTENCE)}</div> |
| {served} |
| </div> |
| </article>""" |
|
|
|
|
| |
|
|
|
|
| def chips(attachments: list[dict], session_path: str | None) -> str: |
| chip_html = "" |
| if attachments: |
| chip_html = '<div class="chip-row">' + "".join( |
| f'<span class="chip">{escape(a.get("label", "exhibit"))}</span>' for a in attachments |
| ) + "</div>" |
| session = "" |
| if session_path: |
| session = ( |
| '<div class="session-line">Court session created: ' |
| f"<strong>{escape(session_path)}</strong> — use Invite to copy.</div>" |
| ) |
| return chip_html + session |
|
|
|
|
| |
|
|
|
|
| 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""" |
| <article class="surface"> |
| <div class="surface-pad"> |
| <h1 class="prompt">You have been summoned.</h1> |
| <p class="subprompt">Court session {where} is already in progress.{title} |
| Choose your level of involvement.</p> |
| </div> |
| </article>""" |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| def _hero_card(ex: data.LandingExample, k: int) -> str: |
| """One faux court record; `--k` is its slot index in the CSS rotation.""" |
| return f""" |
| <div class="landing-hero" style="--k:{k}"> |
| <div class="landing-hero-stamp">{escape(ex.stamp)}</div> |
| <div class="landing-hero-head"> |
| <span class="landing-hero-kicker">Now on the docket</span> |
| <span class="landing-hero-case">{escape(ex.case_id)}</span> |
| </div> |
| <p class="landing-hero-complaint">“{escape(ex.complaint)}”</p> |
| <h2 class="landing-hero-charge">{escape(ex.charge)}</h2> |
| <div class="landing-hero-verdict"> |
| <span class="landing-hero-tag">Verdict</span>{escape(ex.judgement)} |
| </div> |
| <div class="landing-hero-sentence"> |
| <span class="landing-hero-tag">Sentence</span>{escape(ex.sentence)} |
| </div> |
| </div>""" |
|
|
|
|
| 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}%" |
| 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"<style>{keyframes}</style>" if keyframes else "" |
| return ( |
| '<div class="landing-hero-rotator" id="landingHero" aria-label="Example court record"' |
| f' style="--n:{len(dockets)}">{cards}{style}</div>' |
| ) |
|
|
|
|
| |
|
|
|
|
| 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}"' |
| ) |
|
|