"""Pure HTML renderer for the Fishbowl narrator/event feed (the transcript).
This mirrors the ``Feed`` component of the React prototype (``ui/raw/show.jsx``) and the
feed styling in ``ui/raw/show.css``. It is a *pure* string function over the view-model
dict produced by :func:`src.ui.fishbowl.view_model_at` — no Gradio import, no sibling
render imports — so the same projection can be served as HTML by the Gradio shell or a
future ``gr.Server``.
Each feed item (``vm["feed"][i]``) is tagged by ``kind``:
* ``narrate{voice,text}`` → the narrator's voice + line, with an optional typewriter
slice on the *head* (latest) narrate item.
* ``say{agent,said,thought,mood}`` → an agent name + what it said aloud, and — when the
mind-reader is on and a thought exists — the inner thought in a ``thought`` span.
* ``poke{label,text}`` → a bolt-tagged disturbance line.
* ``verdict{text,reveal,agent}`` → a lime-accented verdict line.
Every piece of model/agent text is escaped with :func:`html.escape`.
"""
from __future__ import annotations
import html
from src.ui.fishbowl.adapter import agent_color, agent_color_dim
_BOLT = "⚡" # poke lines carry a lightning bolt
_SCALES = "⚖" # the verdict line carries the scales of judgement
_QUILL = "✒" # the rafters-critic tags its color commentary
def _last_narrate_index(feed: list[dict]) -> int | None:
"""Index of the latest ``narrate`` item — the one that gets the typewriter slice."""
for i in range(len(feed) - 1, -1, -1):
if feed[i].get("kind") == "narrate":
return i
return None
def _narrate_line(*, voice_name: str, text: str, typing: bool) -> str:
speaker = html.escape(voice_name)
body = html.escape(text)
p_cls = ' class="caret"' if typing else ""
return f'
{speaker}
{body}
'
def _say_line(item: dict, *, mind_reader: bool) -> str:
name = html.escape(item.get("agent") or "")
said = html.escape(item.get("said") or "")
line = f'
{name}{said}
'
thought = item.get("thought")
if mind_reader and thought:
thought_html = html.escape(thought)
line += f'
↳ {thought_html}
'
# The line wears the speaker's own phosphor (name, accent border, thought tint) so the
# transcript is colour-coded to the cast — the same hue as their MindCard and avatar.
# A hue-less line (e.g. an un-cast actor) falls back to the CSS default accent.
hue = item.get("hue")
style = f' style="--ac:{agent_color(int(hue))};--acd:{agent_color_dim(int(hue))}"' if hue is not None else ""
return f'
{line}
'
def _poke_line(item: dict) -> str:
label = html.escape(item.get("label") or "DISTURBANCE")
text = html.escape(item.get("text") or "")
return f'
{_BOLT} {label}
{text}
'
def _verdict_line(item: dict) -> str:
text = html.escape(item.get("text") or "")
return f'
{_SCALES} VERDICT
{text}
'
def _commentate_line(item: dict) -> str:
"""A color-commentary card: the critic's funny line, with badges for any media.
The image and audio themselves render as NATIVE gr.Image / gr.Audio in the "FROM THE
RAFTERS" cutaway (app shell), not as inline ````/``