"""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 ````/``