"""Unit: the empty answer bubble never paints (phase 166, task 01, ``TODO.md`` L4 — "There's an empty box where the LLM response will go that shows up before the LLM responds. It looks ugly." (owner's ``image.png``: Thinking / Tool calls (2) / an expanded Thinking run, then a bordered, padded, EMPTY rounded box)). Why the box exists: the first ``thinking``/``tool`` frame of a turn calls ``addMessage("brain", "")`` (app.js ~4063 / ~4164 / ~4269) — the EMPTY bubble is created ON PURPOSE as the DOM anchor for ``ensureReasoningWrap`` (``body.insertBefore(cont, body.querySelector(".bubble"))`` — the phase-158/161 per-run timeline sits ABOVE the answer bubble). ``.bubble`` carries its own padding + border + background + box-shadow, so an empty one paints the reported ~40px box (and ``.msg-body``'s flex gap adds the separation). The fix is CSS-only (LOCKED A1 — owner 2026-07-10: "hide until the first token", NOT "restructure the anchor"): ``.msg .bubble:empty { display: none; }`` in the Messages block. ``:empty`` matches only a child-less element — exactly ``innerHTML === ""`` — and the first delta writes content, so the box appears AT the first token; ``display: none`` also drops the flex item so the gap collapses. Because ``renderMarkdown("")`` returns ``""`` (node-executed below), the restore path and the shared page (same ``.msg/.msg-body/.bubble`` structure, same stylesheet — locked A3) are covered by the same rule. The typing indicator is never ``:empty`` (three dot ````s), so the loading state never disappears (locked A2 — nothing replaces the box). Browser behavior is E2E-covered by ``tests/e2e/test_no_empty_box.py`` (task 03); here we pin the source-level contract in the house "read-the-source" style (test_thinking_scroll.py / test_markdown_tables.py): the exact declaration exists ONCE in the Messages block, ``.bubble`` keeps its paint (the rule HIDES, never restyles), the typing-dot rules are untouched, the JS anchors survive unchanged (D2 — this is the pin that stops a future "cleanup" from moving the fix into JS and breaking the timeline), and the empty-input renderer behavior is executed under node. AUDIT NOTE (phase 166 task 02 — the §7.4 "never stale" inventory; the record the next agent reads instead of re-deriving it). With the empty bubble suppressed, EVERY mid-turn state still has a visible affordance, in order: • idle / pre-thinking → ``#typing-indicator``: the ``role="status"`` bubble labelled ``TYPING_LABEL()`` with its three dot ````s (never ``:empty``, so the rule can never hide it); • long tool call / frameless silence → the ``thinkingClock`` still paints the ``Ns`` ``.typing-elapsed`` suffix INTO that same bubble (a child ⇒ the bubble is never ``:empty`` ⇒ the clock keeps painting); • thinking / tool → the ``details.reasoning`` container (``ensureReasoningWrap`` sets ``cont.open = true`` — visible while live) with its live status span: "Thinking…" / "Calling …" / the settle "N steps"; • first token → the answer bubble APPEARS: the first ``delta`` writes ``innerHTML``, so ``:empty`` stops matching on the SAME frame the box returns — and ``#messages`` (the ONE conversation ``aria-live="polite"`` region) announces as content lands, at which point the bubble is visible again anyway; • terminal → the settle copy: answer / deflection (.maybe-try chips) / stopped (.stopped-note) / failed (.failed-note) + the ``#send-status`` / ``#send-label`` composer signals. Nothing measures the hidden box (``display: none`` yields zero rects), so no scroll or pin can shift: ``scrollReveal`` receives the ``.msg`` WRAP (never the bubble), the phase-161 window pins take ``.reasoning-body``, and ``.bubble`` is only ever read for CONTENT (``textContent`` / ``innerHTML`` / ``querySelector`` / ``classList`` / ``prepend`` / ``setAttribute``) or used as an ``insertBefore`` anchor. The ``.msg-meta`` rows (chips, figures, actions, notes) append to ``.msg-body`` as SIBLINGS of the bubble (phase 130), so hiding an empty one never hides them. The restore (``addMessage("brain", renderMarkdown(m.text), true)``) and the shared page (``shared.js`` builds the identical ``.msg/.msg-body/.bubble`` structure on the same stylesheet) land in the same ``.msg `` scope — no ``shared.js`` change needed (locked A3). """ from __future__ import annotations import json import re import shutil import subprocess from pathlib import Path import pytest FRONTEND = Path(__file__).resolve().parents[2] / "frontend" ASSETS = FRONTEND / "assets" APP_JS = ASSETS / "app.js" MARKDOWN_JS = ASSETS / "markdown.js" SHARED_JS = ASSETS / "shared.js" STYLES_CSS = ASSETS / "styles.css" INDEX_HTML = FRONTEND / "index.html" RULE = ".msg .bubble:empty { display: none; }" HAVE_NODE = shutil.which("node") is not None def _text(path: Path) -> str: assert path.is_file(), f"missing frontend file: {path}" return path.read_text(encoding="utf-8") def _css() -> str: return _text(STYLES_CSS) def _js() -> str: return _text(APP_JS) def _bubble_rule(css: str) -> str: """Body of the bare `.bubble { ... }` rule (the answer bubble).""" rule = re.search(r"\n\.bubble \{([\s\S]*?)\n\}", css) assert rule, "styles.css must style .bubble" return rule.group(1) def _function_body(js: str, signature: str) -> str: """Source of one top-level function, from its signature to the first column-0 `}` (the house indentation makes that the function end). Used by the task-02 audit pins.""" start = js.find(signature) assert start != -1, f"missing function: {signature}" end = js.find("\n}", start) assert end != -1, f"unterminated function: {signature}" return js[start:end] def _strip_comments(src: str) -> str: """Crude-but-sufficient comment stripper for the audit greps: block comments then line comments (the house files carry no `//` inside string literals — no-CDN pins keep URLs out).""" src = re.sub(r"/\*[\s\S]*?\*/", "", src) return re.sub(r"//[^\n]*", "", src) def _run_node(script: str) -> str: proc = subprocess.run(["node", "-e", script], capture_output=True, text=True, timeout=60) assert proc.returncode == 0, f"node failed: {proc.stderr}" return proc.stdout # ---------- styles.css: the one rule ---------- def test_suppression_rule_exists_exactly_once() -> None: """The exact declaration ``.msg .bubble:empty { display: none; }`` exists in styles.css EXACTLY once — one rule, no duplicates, no near-miss variant (a `.bubble:empty` without the `.msg` scope would leak past the Messages section).""" css = _css() assert css.count(RULE) == 1, "the suppression rule must exist exactly once" assert css.count(".bubble:empty") == 1, "no unscoped or extra :empty variant" def test_rule_sits_in_the_messages_block() -> None: """Provenance ordering (the test_date_editor.py css.find idiom): the rule lives in the Messages block, directly after the ``.bubble p`` content rule and before ``.bubble pre`` — i.e. after ``.bubble {`` — so the fix reads next to the bubble it hides.""" css = _css() assert css.find("---------- Messages ----------") < css.find(RULE), ( "the rule must sit in the Messages block" ) assert css.find(".bubble {") < css.find(".bubble p {") < css.find(RULE), ( "the rule must follow the .bubble rules it suppresses" ) assert css.find(RULE) < css.find(".bubble pre {"), ( "the rule must stay between .bubble p and .bubble pre" ) def test_rule_is_scoped_to_msg_bubbles() -> None: """The selector is `.msg .bubble:empty` — the chat/shared-page message bubbles ONLY. The document viewer's .doc-* blocks, the composer, and every other :empty-carrying element never match.""" css = _css() assert RULE in css # Every `.bubble:empty` occurrence carries the `.msg ` prefix — no # bare variant escaping the Messages scope — and there is no # broader `.msg:empty` (which would hide the whole message wrap). for m in re.finditer(r"\.bubble:empty", css): assert css[: m.start()].endswith(".msg "), ( "the :empty rule must stay scoped to .msg .bubble" ) assert ".msg:empty" not in css def test_bubble_keeps_its_paint_the_rule_hides_never_restyles() -> None: """The bare `.bubble` rule still declares padding, border-radius, background, border and box-shadow (LOCKED A1: the fix HIDES the empty box, it never restyles a non-empty bubble) — and no `display: none` crept into the base rule itself.""" body = _bubble_rule(_css()) for decl in ("padding", "border-radius", "background", "border", "box-shadow"): assert decl in body, f".bubble must keep its {decl} (hide, not restyle)" assert "display: none" not in body, "the base .bubble rule must stay visible" def test_suppression_carries_phase_166_provenance() -> None: """The provenance comment directly above the rule names the defect (TODO L4 + the owner's image.png), the JS anchors it preserves (addMessage("brain","") / ensureReasoningWrap), and why the typing indicator is unaffected.""" css = _css() idx = css.find(RULE) comment = css[:idx] start = comment.rindex("/*") note = css[start:idx] assert "Phase 166" in note assert "TODO L4" in note, "the comment must name the TODO line" assert "image.png" in note, "the comment must name the owner's screenshot" assert 'addMessage("brain","")' in note or 'addMessage("brain", "")' in note, ( "the comment must name the empty-bubble creation it hides" ) assert "ensureReasoningWrap" in note, "the comment must name the preserved anchor" assert "typing" in note, "the comment must explain why the indicator is safe" def test_typing_indicator_rules_are_untouched() -> None: """The loading affordance stays painted: the `.typing` bubble rule (inline-flex + gap) and the three-dot animation rules (`.typing span`, the two animation-delays, @keyframes typing) are all still present — the indicator bubble carries the dot children, so the new :empty rule can never hide it (locked A2).""" css = _css() assert ".typing { display: inline-flex" in css assert "animation: typing 1.2s infinite ease-in-out;" in css assert ".typing span:nth-child(2) { animation-delay: 0.15s; }" in css assert ".typing span:nth-child(3) { animation-delay: 0.3s; }" in css assert "@keyframes typing {" in css # ---------- app.js: the CSS-only contract (D2) ---------- def test_addtyping_bubble_is_never_empty() -> None: """`addTyping()` still builds the bubble with its three dot `` children — `:empty` matches only a CHILD-LESS element, so the typing indicator (role=status) is provably never hidden by the new rule: the loading state cannot disappear.""" js = _js() start = js.find("function addTyping() {") assert start != -1, "app.js must keep addTyping()" body = js[start : js.find("\n}", start)] assert '
" in body, ( "the three dot spans are the non-:empty guarantee" ) def test_the_three_empty_bubble_anchors_survive() -> None: """D2 pin: app.js is NOT modified by this phase — all three `addMessage("brain", "")` call sites (thinking ~4063 / tool ~4164 / first delta ~4269) still create the empty wrap. A future "cleanup" that moves the fix into JS would re-own the phase-158/161 anchors for zero user-visible gain over the CSS rule — this pin stops it.""" js = _js() assert js.count('addMessage("brain", "")') == 3, ( "the thinking/tool/first-delta empty-bubble anchors must all stay" ) def test_ensure_reasoning_wrap_still_anchors_before_the_bubble() -> None: """`ensureReasoningWrap` still does `body.insertBefore(cont, body.querySelector(".bubble"))` — the hidden bubble remains the DOM anchor the per-run timeline inserts BEFORE (the CSS rule hides the box; it never removes the element).""" js = _js() start = js.find("function ensureReasoningWrap(wrap) {") assert start != -1, "app.js must keep ensureReasoningWrap" body = js[start : js.find("\n}", start)] assert 'body.insertBefore(cont, body.querySelector(".bubble"))' in body # ---------- markdown.js: WHY one rule covers restore + shared (A3) ---------- @pytest.mark.skipif(not HAVE_NODE, reason="node not available") def test_rendermarkdown_empty_inputs_yield_empty_string() -> None: """Executed under node (the phase-44 driver idiom): `renderMarkdown("")` and `renderMarkdown(" ")` BOTH return `""` — so a persisted answer with empty/whitespace text lands as a truly child-less `
` in the restore AND the shared page, and the same `:empty` rule covers them (locked A3).""" js = _text(MARKDOWN_JS) driver = ( js + "\nconst out = { empty: renderMarkdown(\"\"), blank: renderMarkdown(\" \") };\n" + "console.log(JSON.stringify(out));" ) results = json.loads(_run_node(driver)) assert results["empty"] == "", f'renderMarkdown("") must be "", got {results["empty"]!r}' assert results["blank"] == "", f'renderMarkdown(" ") must be "", got {results["blank"]!r}' # ---------- no-CDN (AGENTS.md rule 6) ---------- def test_no_cdn_introduced() -> None: """The touched assets stay CDN-free (AGENTS.md rule 6): no remote url()/@import in styles.css, no remote