From 5f3ac3dc3c0ea9ab76163508ebaa55e7f7a85d80 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Thu, 11 Jun 2026 19:29:12 +0200 Subject: [PATCH 01/12] Side-channel user prompts: collapsible Markdown + embedded-JSON extraction (#174 follow-up) Workflow sub-agent prompts are large prose+JSON hybrids that rendered as a wall of text. Two complementary changes, applied only to user messages grafted from a workflow agent's side-channel (tagged by the graft pass): - The prompt renders through the escaping Markdown collapsible (these prompts run long), via format_workflow_sidechannel_user_content. - Embedded pretty-printed JSON blocks are extracted first: a lone { or [ on its own line, through a lone matching closer followed by a blank line (or EOF), accepted only when json.loads parses it. Each block is substituted with a z-prefixed UUID placeholder (every uuid group gets a z so the SHA->commit-URL linkifier can never match inside it), the remaining text renders as Markdown, and each placeholder is then swapped for the generic params-table rendering of the parsed value (arrays as index->value rows) - which the hybrid JSON/Markdown renderer upgrades automatically once it lands. A placeholder landing in a fold's preview becomes a compact { } hint there; the table renders once, in the body. Fixture: the first workflow_basic agent prompt now embeds a JSON block to exercise the path end-to-end. On a real 42-agent run, 34 embedded blocks across 40 side-channel prompts extract into tables. Trunk/non-workflow user rendering is untouched (gated on the graft tag); snapshot diff is CSS-only. Co-Authored-By: Claude Fable 5 --- claude_code_log/html/renderer.py | 10 +- .../templates/components/message_styles.css | 12 ++ claude_code_log/html/user_formatters.py | 124 +++++++++++- claude_code_log/renderer.py | 7 + scripts/gen_workflow_fixture.py | 19 +- test/__snapshots__/test_snapshot_html.ambr | 84 +++++++++ .../workflows/wf_demo01/agent-ag000001.jsonl | 2 +- test/test_workflow_sidechannel_user.py | 176 ++++++++++++++++++ 8 files changed, 430 insertions(+), 4 deletions(-) create mode 100644 test/test_workflow_sidechannel_user.py diff --git a/claude_code_log/html/renderer.py b/claude_code_log/html/renderer.py index 8e62f0b8..313d167c 100644 --- a/claude_code_log/html/renderer.py +++ b/claude_code_log/html/renderer.py @@ -122,6 +122,7 @@ format_user_memory_content, format_user_slash_command_content, format_user_text_model_content, + format_workflow_sidechannel_user_content, ) from .assistant_formatters import ( format_assistant_text_content, @@ -498,8 +499,15 @@ def format_SessionHeaderMessage( # ------------------------------------------------------------------------- def format_UserTextMessage( - self, content: UserTextMessage, _: TemplateMessage + self, content: UserTextMessage, message: TemplateMessage ) -> str: + # User prompts grafted from a workflow agent's side-channel are large + # prose+JSON hybrids: render them as collapsible Markdown with the + # embedded JSON blocks extracted into params tables (#174 follow-up). + if message.in_workflow_sidechannel: + return format_workflow_sidechannel_user_content( + content, image_formatter=self._format_image + ) return format_user_text_model_content( content, image_formatter=self._format_image ) diff --git a/claude_code_log/html/templates/components/message_styles.css b/claude_code_log/html/templates/components/message_styles.css index 971b473d..e879ebf2 100644 --- a/claude_code_log/html/templates/components/message_styles.css +++ b/claude_code_log/html/templates/components/message_styles.css @@ -1554,3 +1554,15 @@ a.workflow-phase-pill:hover { color: var(--text-muted); font-size: 0.9em; } + +/* Embedded JSON blocks extracted from workflow side-channel user prompts + (#174 follow-up): the block renders as a params table in place of the raw + JSON text; the hint marks a block whose table lives below the fold. */ +.embedded-json { + margin: 0.5em 0; +} + +.embedded-json-hint { + color: var(--text-muted); + font-family: var(--font-monospace); +} diff --git a/claude_code_log/html/user_formatters.py b/claude_code_log/html/user_formatters.py index e1b46cbc..96750fa7 100644 --- a/claude_code_log/html/user_formatters.py +++ b/claude_code_log/html/user_formatters.py @@ -8,7 +8,9 @@ - tool_formatters.py: tool use/result content """ -from typing import Callable, Optional +import json +import uuid +from typing import Any, Callable, Optional, cast from .ansi_colors import convert_ansi_to_html from ..models import ( @@ -33,6 +35,7 @@ render_collapsible_code, render_markdown_collapsible, render_user_markdown, + render_user_markdown_collapsible, ) @@ -272,6 +275,125 @@ def format_user_text_model_content( return "\n".join(parts) +def _json_placeholder() -> str: + """A substitution token that survives Markdown rendering verbatim. + + Each uuid4 group is prefixed with ``z`` so no bare 7–40 char hex run + remains — otherwise the SHA→commit-URL linkifier (``SHA_PATTERN`` in + ``markdown_plugins.py``) would wrap parts of the placeholder in a link + and break the back-substitution. + """ + return "-".join(f"z{part}" for part in str(uuid.uuid4()).split("-")) + + +def extract_embedded_json(text: str) -> "tuple[str, dict[str, Any]]": + """Pull pretty-printed JSON blocks out of a prose+JSON prompt. + + Workflow sub-agent prompts routinely embed large JSON objects/arrays in + otherwise-Markdown prose. The block shape (the only reliable hint): a + lone ``{`` or ``[`` on its own line, through a lone matching closer + whose next line is blank (or EOF). A candidate only counts when + ``json.loads`` accepts it — anything else is left untouched. + + Returns ``(substituted_text, {placeholder: parsed_value})`` where each + block is replaced by a unique placeholder line (see + :func:`_json_placeholder`); the caller renders the remaining text as + Markdown and then swaps each placeholder for a structured rendering of + its parsed value. Multiple blocks are supported. + """ + lines = text.split("\n") + out_lines: list[str] = [] + blocks: dict[str, Any] = {} + closer_for = {"{": "}", "[": "]"} + i = 0 + while i < len(lines): + stripped = lines[i].strip() + if stripped in closer_for: + # Scan to the next blank line (or EOF) — the candidate block end. + j = i + 1 + while j < len(lines) and lines[j].strip(): + j += 1 + if j - 1 > i and lines[j - 1].strip() == closer_for[stripped]: + candidate = "\n".join(lines[i:j]) + try: + parsed: Any = json.loads(candidate) + except ValueError: + parsed = None + if isinstance(parsed, (dict, list)) and parsed: + placeholder = _json_placeholder() + blocks[placeholder] = parsed + out_lines.append(placeholder) + i = j + continue + out_lines.append(lines[i]) + i += 1 + return "\n".join(out_lines), blocks + + +def _embedded_json_html(parsed: Any) -> str: + """Structured rendering for an extracted JSON block — the generic tool + params table (upgraded to the hybrid JSON/Markdown renderer when that + lands), with arrays presented as index→value rows.""" + if isinstance(parsed, dict): + params = {str(k): v for k, v in cast("dict[Any, Any]", parsed).items()} + else: + params = {str(i): v for i, v in enumerate(cast("list[Any]", parsed))} + return f"
{render_params_table(params)}
" + + +def format_workflow_sidechannel_user_text(text: str) -> str: + """Render a workflow sub-agent prompt: collapsible Markdown with embedded + JSON blocks extracted into params tables (#174 follow-up). + + The text is preprocessed by :func:`extract_embedded_json`, rendered + through the escaping Markdown collapsible (these prompts are large), and + the placeholders are then swapped for table renderings. A placeholder + that lands in the fold's *preview* (the first few lines) becomes a + compact ``{…}`` hint there instead — the table belongs in the body, not + the summary. + """ + substituted, blocks = extract_embedded_json(text) + html = render_user_markdown_collapsible( + substituted, + "workflow-sidechannel-user", + line_threshold=12, + preview_line_count=5, + ) + for placeholder, parsed in blocks.items(): + table = _embedded_json_html(parsed) + if "" in html: + head, tail = html.split("", 1) + head = head.replace( + placeholder, "{…}" + ) + html = head + "" + tail.replace(placeholder, table) + else: + html = html.replace(placeholder, table) + return html + + +def format_workflow_sidechannel_user_content( + content: UserTextMessage, + image_formatter: Optional[Callable[[ImageContent], str]] = None, +) -> str: + """Variant of :func:`format_user_text_model_content` for user messages + grafted from a workflow agent's side-channel: text items get the + collapsible + embedded-JSON treatment; other items render as usual.""" + from .assistant_formatters import format_image_content + + formatter = image_formatter or format_image_content + parts: list[str] = [] + for item in content.items: + if isinstance(item, IdeNotificationContent): + parts.extend(format_ide_notification_content(item)) + elif isinstance(item, ImageContent): + parts.append(formatter(item)) + else: # TextContent + if item.text.strip(): + parts.append(format_workflow_sidechannel_user_text(item.text)) + return "\n".join(parts) + + def format_compacted_summary_content(content: CompactedSummaryMessage) -> str: """Format compacted session summary content as HTML. diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 7d7e437d..dc5ddd4b 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -262,6 +262,12 @@ def __init__( # Children for tree-based rendering self.children: list["TemplateMessage"] = [] + # Set by _graft_agent_sidechannel (#174): True for every node grafted + # from a workflow agent's side-channel transcript. Formatters use it + # to render those user prompts as collapsible Markdown with embedded + # JSON blocks extracted into params tables. + self.in_workflow_sidechannel: bool = False + # Per-render annotations populated by the HTML renderer's tree walk # (HtmlRenderer._annotate_tree_for_render). The recursive template # macro reads these instead of receiving a flat (msg, title, html, @@ -3073,6 +3079,7 @@ def _graft_agent_sidechannel( def _reindex(node: TemplateMessage, parent: TemplateMessage) -> None: old = node.message_index ctx.register(node) + node.in_workflow_sidechannel = True if old is not None and node.message_index is not None: old_to_new[old] = node.message_index if parent.message_index is not None: diff --git a/scripts/gen_workflow_fixture.py b/scripts/gen_workflow_fixture.py index 4d72c603..d85cb731 100644 --- a/scripts/gen_workflow_fixture.py +++ b/scripts/gen_workflow_fixture.py @@ -47,6 +47,23 @@ "phaseIndex": 1, "phaseTitle": "Map", "model": "claude-sonnet-4-6", + # Realistic prose+JSON prompt shape: workflow agent prompts routinely + # embed pretty-printed JSON blocks (a lone `{`/`[` line through a lone + # closer + blank line). Exercises the embedded-JSON extraction in the + # side-channel user rendering. + "prompt": ( + "You are a focused reviewer. Map the **loader** area.\n" + "\n" + "A prior analysis proposed this opportunity:\n" + "\n" + "{\n" + ' "id": "loader-glob",\n' + ' "title": "Extend the discovery glob",\n' + ' "touches": ["converter.py"]\n' + "}\n" + "\n" + "Verify it against the current code and report findings." + ), "result": { "area": "loader", "summary": "Discovery glob misses subagents/workflows.", @@ -283,7 +300,7 @@ def _agent_transcript(agent: dict) -> list[dict]: f"{aid}_u1", None, sid, - [{"type": "text", "text": agent["label"]}], + [{"type": "text", "text": agent.get("prompt", agent["label"])}], sidechain=True, agent_id=aid, ), diff --git a/test/__snapshots__/test_snapshot_html.ambr b/test/__snapshots__/test_snapshot_html.ambr index 43a99b29..ce978ce4 100644 --- a/test/__snapshots__/test_snapshot_html.ambr +++ b/test/__snapshots__/test_snapshot_html.ambr @@ -1913,6 +1913,18 @@ color: var(--text-muted); font-size: 0.9em; } + + /* Embedded JSON blocks extracted from workflow side-channel user prompts + (#174 follow-up): the block renders as a params table in place of the raw + JSON text; the hint marks a block whose table lives below the fold. */ + .embedded-json { + margin: 0.5em 0; + } + + .embedded-json-hint { + color: var(--text-muted); + font-family: var(--font-monospace); + } /* Session navigation styles */ .navigation { background-color: var(--bg-neutral); @@ -8109,6 +8121,18 @@ color: var(--text-muted); font-size: 0.9em; } + + /* Embedded JSON blocks extracted from workflow side-channel user prompts + (#174 follow-up): the block renders as a params table in place of the raw + JSON text; the hint marks a block whose table lives below the fold. */ + .embedded-json { + margin: 0.5em 0; + } + + .embedded-json-hint { + color: var(--text-muted); + font-family: var(--font-monospace); + } /* Session navigation styles */ .navigation { background-color: var(--bg-neutral); @@ -16392,6 +16416,18 @@ color: var(--text-muted); font-size: 0.9em; } + + /* Embedded JSON blocks extracted from workflow side-channel user prompts + (#174 follow-up): the block renders as a params table in place of the raw + JSON text; the hint marks a block whose table lives below the fold. */ + .embedded-json { + margin: 0.5em 0; + } + + .embedded-json-hint { + color: var(--text-muted); + font-family: var(--font-monospace); + } /* Session navigation styles */ .navigation { background-color: var(--bg-neutral); @@ -22703,6 +22739,18 @@ color: var(--text-muted); font-size: 0.9em; } + + /* Embedded JSON blocks extracted from workflow side-channel user prompts + (#174 follow-up): the block renders as a params table in place of the raw + JSON text; the hint marks a block whose table lives below the fold. */ + .embedded-json { + margin: 0.5em 0; + } + + .embedded-json-hint { + color: var(--text-muted); + font-family: var(--font-monospace); + } /* Session navigation styles */ .navigation { background-color: var(--bg-neutral); @@ -29281,6 +29329,18 @@ color: var(--text-muted); font-size: 0.9em; } + + /* Embedded JSON blocks extracted from workflow side-channel user prompts + (#174 follow-up): the block renders as a params table in place of the raw + JSON text; the hint marks a block whose table lives below the fold. */ + .embedded-json { + margin: 0.5em 0; + } + + .embedded-json-hint { + color: var(--text-muted); + font-family: var(--font-monospace); + } /* Session navigation styles */ .navigation { background-color: var(--bg-neutral); @@ -35821,6 +35881,18 @@ color: var(--text-muted); font-size: 0.9em; } + + /* Embedded JSON blocks extracted from workflow side-channel user prompts + (#174 follow-up): the block renders as a params table in place of the raw + JSON text; the hint marks a block whose table lives below the fold. */ + .embedded-json { + margin: 0.5em 0; + } + + .embedded-json-hint { + color: var(--text-muted); + font-family: var(--font-monospace); + } /* Session navigation styles */ .navigation { background-color: var(--bg-neutral); @@ -42305,6 +42377,18 @@ color: var(--text-muted); font-size: 0.9em; } + + /* Embedded JSON blocks extracted from workflow side-channel user prompts + (#174 follow-up): the block renders as a params table in place of the raw + JSON text; the hint marks a block whose table lives below the fold. */ + .embedded-json { + margin: 0.5em 0; + } + + .embedded-json-hint { + color: var(--text-muted); + font-family: var(--font-monospace); + } /* Session navigation styles */ .navigation { background-color: var(--bg-neutral); diff --git a/test/test_data/workflow_basic/11110000-0000-4000-8000-000000000001/subagents/workflows/wf_demo01/agent-ag000001.jsonl b/test/test_data/workflow_basic/11110000-0000-4000-8000-000000000001/subagents/workflows/wf_demo01/agent-ag000001.jsonl index dd84c606..92cdcba2 100644 --- a/test/test_data/workflow_basic/11110000-0000-4000-8000-000000000001/subagents/workflows/wf_demo01/agent-ag000001.jsonl +++ b/test/test_data/workflow_basic/11110000-0000-4000-8000-000000000001/subagents/workflows/wf_demo01/agent-ag000001.jsonl @@ -1,3 +1,3 @@ -{"type": "user", "uuid": "ag000001_u1", "parentUuid": null, "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "11110000-0000-4000-8000-000000000001#agent-ag000001", "version": "2.1.2", "timestamp": "2026-06-04T10:00:00.000Z", "agentId": "ag000001", "message": {"role": "user", "content": [{"type": "text", "text": "review:loader"}]}} +{"type": "user", "uuid": "ag000001_u1", "parentUuid": null, "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "11110000-0000-4000-8000-000000000001#agent-ag000001", "version": "2.1.2", "timestamp": "2026-06-04T10:00:00.000Z", "agentId": "ag000001", "message": {"role": "user", "content": [{"type": "text", "text": "You are a focused reviewer. Map the **loader** area.\n\nA prior analysis proposed this opportunity:\n\n{\n \"id\": \"loader-glob\",\n \"title\": \"Extend the discovery glob\",\n \"touches\": [\"converter.py\"]\n}\n\nVerify it against the current code and report findings."}]}} {"type": "assistant", "uuid": "ag000001_a1", "parentUuid": "ag000001_u1", "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "11110000-0000-4000-8000-000000000001#agent-ag000001", "version": "2.1.2", "timestamp": "2026-06-04T10:00:00.000Z", "agentId": "ag000001", "message": {"id": "msg_ag000001_a1", "type": "message", "role": "assistant", "model": "claude-sonnet-4-6", "stop_reason": "end_turn", "content": [{"type": "text", "text": "Working on review:loader."}, {"type": "tool_use", "id": "toolu_ag000001_so", "name": "StructuredOutput", "input": {"area": "loader", "summary": "Discovery glob misses subagents/workflows.", "key_functions": ["_scan_sidechain_uuids", "load_directory_transcripts"], "opportunities": ["extend glob to subagents/workflows/"]}}], "usage": {"input_tokens": 5, "output_tokens": 5}}} {"type": "assistant", "uuid": "ag000001_a2", "parentUuid": "ag000001_a1", "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "11110000-0000-4000-8000-000000000001#agent-ag000001", "version": "2.1.2", "timestamp": "2026-06-04T10:00:00.000Z", "agentId": "ag000001", "message": {"id": "msg_ag000001_a2", "type": "message", "role": "assistant", "model": "claude-sonnet-4-6", "stop_reason": "end_turn", "content": [{"type": "text", "text": "Returning structured output."}], "usage": {"input_tokens": 5, "output_tokens": 5}}} diff --git a/test/test_workflow_sidechannel_user.py b/test/test_workflow_sidechannel_user.py new file mode 100644 index 00000000..47a0f236 --- /dev/null +++ b/test/test_workflow_sidechannel_user.py @@ -0,0 +1,176 @@ +"""Workflow side-channel user prompts: collapsible Markdown with embedded +JSON blocks extracted into params tables (#174 follow-up). + +The prompts that drive workflow sub-agents are large prose+JSON hybrids. +``extract_embedded_json`` pulls out pretty-printed JSON blocks (a lone +``{``/``[`` line through a lone matching closer followed by a blank line), +substituting z-prefixed UUID placeholders that survive Markdown rendering; +``format_workflow_sidechannel_user_text`` renders the remainder as escaping +collapsible Markdown and swaps each placeholder for a params-table rendering. +""" + +from __future__ import annotations + +from pathlib import Path + +from claude_code_log.html.user_formatters import ( + extract_embedded_json, + format_workflow_sidechannel_user_text, +) + +TRUNK = ( + Path(__file__).parent + / "test_data" + / "workflow_basic" + / "11110000-0000-4000-8000-000000000001.jsonl" +) + +_OBJECT_BLOCK = '{\n "id": "x",\n "touches": ["a.py"]\n}' +_ARRAY_BLOCK = '[\n {"area": "loader"},\n {"area": "tree"}\n]' + + +class TestExtractEmbeddedJson: + def test_object_block_extracted(self) -> None: + text = f"Intro prose.\n\n{_OBJECT_BLOCK}\n\nOutro." + substituted, blocks = extract_embedded_json(text) + assert len(blocks) == 1 + placeholder, parsed = next(iter(blocks.items())) + assert parsed == {"id": "x", "touches": ["a.py"]} + assert placeholder in substituted + assert '"touches"' not in substituted + + def test_array_block_extracted(self) -> None: + substituted, blocks = extract_embedded_json(f"Head:\n\n{_ARRAY_BLOCK}\n\nTail.") + assert list(blocks.values()) == [[{"area": "loader"}, {"area": "tree"}]] + assert "loader" not in substituted + + def test_no_blank_line_before_opener_still_matches(self) -> None: + # The opener needs no preceding blank line — only the closer needs a + # following one. + text = f"INPUT (maps):\n{_ARRAY_BLOCK}\n\nDone." + _substituted, blocks = extract_embedded_json(text) + assert len(blocks) == 1 + + def test_block_at_eof_matches(self) -> None: + # EOF counts as the blank line after the closer. + _substituted, blocks = extract_embedded_json(f"Intro.\n\n{_OBJECT_BLOCK}") + assert len(blocks) == 1 + + def test_multiple_blocks(self) -> None: + text = f"A:\n\n{_OBJECT_BLOCK}\n\nB:\n\n{_ARRAY_BLOCK}\n\nC." + substituted, blocks = extract_embedded_json(text) + assert len(blocks) == 2 + for placeholder in blocks: + assert placeholder in substituted + + def test_invalid_json_left_untouched(self) -> None: + bad = "{\nnot json at all\n}" + substituted, blocks = extract_embedded_json(f"X.\n\n{bad}\n\nY.") + assert blocks == {} + assert "not json at all" in substituted + + def test_blank_line_inside_block_breaks_the_candidate(self) -> None: + # The scan stops at the first blank line; the truncated candidate + # fails to parse and the text stays untouched. + gappy = '{\n "a": 1,\n\n "b": 2\n}' + substituted, blocks = extract_embedded_json(f"X.\n\n{gappy}\n\nY.") + assert blocks == {} + assert '"b": 2' in substituted + + def test_closer_not_alone_left_untouched(self) -> None: + inline_close = '{\n "a": 1 }' + _substituted, blocks = extract_embedded_json(f"X.\n\n{inline_close}\n\nY.") + assert blocks == {} + + def test_prose_brace_paragraph_left_untouched(self) -> None: + # A lone `{` opening a prose paragraph (no matching lone closer + # before the next blank line) must not be eaten. + text = "X.\n\n{\nthis is prose, not JSON\n\nY." + substituted, blocks = extract_embedded_json(text) + assert blocks == {} + assert "this is prose" in substituted + + def test_placeholder_has_no_bare_hex_run(self) -> None: + # Every uuid group is z-prefixed so the SHA→commit-URL linkifier + # (\b[0-9a-f]{7,40}\b) can never match inside a placeholder. + import re + + substituted, blocks = extract_embedded_json(f"P.\n\n{_OBJECT_BLOCK}\n\nQ.") + placeholder = next(iter(blocks)) + assert re.search(r"\b[0-9a-f]{7,40}\b", placeholder) is None + assert placeholder in substituted + + +class TestSidechannelUserRendering: + def test_json_block_renders_as_params_table(self) -> None: + html = format_workflow_sidechannel_user_text( + f"Check this:\n\n{_OBJECT_BLOCK}\n\nThanks." + ) + assert "tool-params-table" in html + assert "embedded-json" in html + # Raw JSON text is gone; values appear in table cells. + assert '"touches"' not in html + assert "a.py" in html + + def test_long_prompt_is_collapsible(self) -> None: + filler = "\n".join(f"Line {i} of prose." for i in range(20)) + html = format_workflow_sidechannel_user_text( + f"{filler}\n\n{_OBJECT_BLOCK}\n\nEnd." + ) + assert " None: + # Block within the first preview lines of a long prompt: the summary + # shows the compact hint; the table renders once, in the body. + filler = "\n".join(f"Line {i}." for i in range(20)) + html = format_workflow_sidechannel_user_text( + f"Top:\n\n{_OBJECT_BLOCK}\n\n{filler}" + ) + head, tail = html.split("", 1) + assert "embedded-json-hint" in head + assert "tool-params-table" not in head + assert "tool-params-table" in tail + + def test_markdown_prose_still_renders(self) -> None: + html = format_workflow_sidechannel_user_text( + f"You are an **ADVERSARIAL** verifier.\n\n{_OBJECT_BLOCK}\n\nGo." + ) + assert "ADVERSARIAL" in html + + def test_user_content_stays_escaped(self) -> None: + html = format_workflow_sidechannel_user_text( + 'Try :\n\n{\n "x": ""\n}\n\nEnd.' + ) + assert ":\n\n{\n "x": ""\n}\n\nEnd.' From d6fca4c072bd249940e0695d463b76ce1c7bb264 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Thu, 11 Jun 2026 20:11:47 +0200 Subject: [PATCH 03/12] Playwright runtime coverage: workflow group-border contract + pill navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands the test refinement suggested in review of the polish PR (deferred here so it pins the FINAL rendered behavior — group borders plus the hybrid params tables together): - Computed-style contract for the :has()-driven group borders: snapshots embed the CSS text, so a DOM-structure change that silently breaks the :has() selectors would not fail them. Asserts the suppressed Workflow-level line (0px), the dark-green agents-group and grey side-channel lines (2px, exact colors), and the border alignment with each parent card's left edge. - Phase-pill navigation: clicking a pill updates the hash and the hashchange handler unfolds the folded target phase card. The pill is first revealed via the page's own anchor-unfold (as a user arriving from session nav would); assertions poll via wait_for_function since hashchange/toggle run asynchronously. Also widens a fixture assertion window: the hybrid renderer now wraps params tables in a tool-params-root with an expand-all control, pushing the table tag past the old 200-char probe. Co-Authored-By: Claude Fable 5 --- test/test_workflow_browser.py | 103 +++++++++++++++++++++++++ test/test_workflow_sidechannel_user.py | 4 +- 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/test/test_workflow_browser.py b/test/test_workflow_browser.py index 1466d181..d0a4bd5b 100644 --- a/test/test_workflow_browser.py +++ b/test/test_workflow_browser.py @@ -82,3 +82,106 @@ def test_phase_fold_control_toggles_agents( # restores it — the fold machine operates on the synthetic phase node. assert result["d1"] != result["d0"] assert result["d2"] == result["d0"] + + +class TestWorkflowRuntimeCss: + """Runtime contract for the workflow group borders (CR on the polish PR): + snapshots embed the CSS *text*, so a DOM-structure change that silently + breaks the ``:has()`` selectors would not fail them — only computed-style + assertions pin the selector↔DOM contract.""" + + @pytest.mark.browser + def test_group_borders_and_alignment(self, page: Page, tmp_path: Path) -> None: + page.goto(_render(tmp_path)) + page.wait_for_timeout(300) + + result = page.evaluate( + """() => { + const phase = document.querySelector('.message.workflow_phase'); + if (!phase) return { found: false }; + const phaseNode = phase.parentElement; // .message-node + const phasesGroup = phaseNode.parentElement; // .children (of the pair) + const agentsGroup = phaseNode.querySelector(':scope > .children'); + const agentNode = agentsGroup && + agentsGroup.querySelector(':scope > .message-node'); + const agent = agentNode && + agentNode.querySelector(':scope > .message.workflow_agent'); + const scGroup = agentNode && + agentNode.querySelector(':scope > .children'); + if (!agent || !scGroup) return { found: false }; + // Reveal folded containers so geometry is honest. + for (const c of [phasesGroup, agentsGroup, scGroup]) { + c.style.display = ''; + } + const cs = (el) => { + const s = getComputedStyle(el); + return { bw: s.borderLeftWidth, bc: s.borderLeftColor }; + }; + const x = (el) => el.getBoundingClientRect().left; + return { + found: true, + phasesGroup: cs(phasesGroup), // suppressed: 0px + agentsGroup: cs(agentsGroup), // dark green, 2px + scGroup: cs(scGroup), // grey, 2px + phaseAligned: Math.abs(x(phase) - x(agentsGroup)) < 1, + agentAligned: Math.abs(x(agent) - x(scGroup)) < 1, + }; + }""" + ) + + assert result.get("found"), "workflow phase/agent structure not found" + # Workflow-level group line is suppressed; phase + agent lines drawn. + assert result["phasesGroup"]["bw"] == "0px" + assert result["agentsGroup"]["bw"] == "2px" + assert result["agentsGroup"]["bc"] == "rgb(27, 94, 32)" # #1b5e20 + assert result["scGroup"]["bw"] == "2px" + assert result["scGroup"]["bc"] == "rgb(158, 158, 158)" # #9e9e9e + # Each group border continues its parent card's border (same x). + assert result["phaseAligned"] is True + assert result["agentAligned"] is True + + +class TestPhasePillNavigation: + """Clicking a phase pill in the Workflow header navigates to the phase + card: the hash updates and the ``hashchange`` handler unfolds the folded + ancestors so the target becomes visible (CR on the polish PR).""" + + @pytest.mark.browser + def test_pill_click_navigates_and_unfolds(self, page: Page, tmp_path: Path) -> None: + page.goto(_render(tmp_path)) + page.wait_for_timeout(300) + + target_id = page.evaluate( + """() => { + const pill = document.querySelector('a.workflow-phase-pill'); + return pill ? pill.getAttribute('href').slice(1) : null; + }""" + ) + assert target_id, "no linked phase pill found" + + hidden_before = page.evaluate( + f"() => document.getElementById('{target_id}').offsetParent === null" + ) + assert hidden_before, "phase card should start folded away" + + # The Workflow card itself starts inside folded ancestors — reveal it + # the way a user arriving from the session nav would: jump to its + # anchor and let the built-in hashchange unfold expose it. + page.evaluate( + """() => { + const card = document.querySelector( + '.message.tool_use:has(.workflow-meta)'); + window.location.hash = '#' + card.id; + }""" + ) + page.wait_for_function( + "() => document.querySelector('a.workflow-phase-pill')" + ".offsetParent !== null" + ) + + page.click("a.workflow-phase-pill") + # The hashchange handler runs asynchronously — poll, don't assume. + page.wait_for_function(f"() => window.location.hash === '#{target_id}'") + page.wait_for_function( + f"() => document.getElementById('{target_id}').offsetParent !== null" + ) diff --git a/test/test_workflow_sidechannel_user.py b/test/test_workflow_sidechannel_user.py index 179867a9..e75a04a5 100644 --- a/test/test_workflow_sidechannel_user.py +++ b/test/test_workflow_sidechannel_user.py @@ -176,7 +176,9 @@ def test_sidechannel_prompt_renders_table_in_directory_load(self) -> None: assert "loader-glob" in html # value surfaced in the table i = html.find("
") assert i != -1 - assert "tool-params-table" in html[i : i + 200] + # The hybrid renderer may wrap the table in a tool-params-root with + # an expand-all control — allow for that chrome before the table. + assert "tool-params-table" in html[i : i + 600] def test_trunk_user_messages_unaffected(self) -> None: from claude_code_log.converter import load_transcript From 20fa631284670290e5a85cfbce75fa985ec52131 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Thu, 11 Jun 2026 20:27:34 +0200 Subject: [PATCH 04/12] Classic sidechain group line: tool-green, per the color-pairing principle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The group line continues its PARENT card's border color — and a standard sub-agent's sidechain hangs under the spawning tool_result, whose border is tool-green. The grey it shipped with paired it to the workflow agent concept instead of to its own parent card; rendering review caught the mismatch. Workflow agents keep their grey (grey workflow_agent card → grey side-channel line — the same principle, different parent). Docs updated; snapshots CSS-only (regenerated serially). Co-Authored-By: Claude Fable 5 --- .../templates/components/message_styles.css | 14 +-- dev-docs/css-classes.md | 2 +- dev-docs/workflows.md | 12 ++- test/__snapshots__/test_snapshot_html.ambr | 98 +++++++++++-------- 4 files changed, 72 insertions(+), 54 deletions(-) diff --git a/claude_code_log/html/templates/components/message_styles.css b/claude_code_log/html/templates/components/message_styles.css index e879ebf2..bc1bf123 100644 --- a/claude_code_log/html/templates/components/message_styles.css +++ b/claude_code_log/html/templates/components/message_styles.css @@ -1464,14 +1464,16 @@ details summary { /* A standard sub-agent's sidechain group (Task-spawned agents — sync, async, teammates): the agent's transcript hangs under the spawning - tool_result, framed by ONE grey line (the same color/concept as a - workflow agent's side-channel — an agent transcript is an agent - transcript). The :not(.sidechain) parent filter keeps the line at the - block's top level only (containers deeper inside the sidechain hang - under .sidechain-flagged cards). */ + tool_result, framed by ONE line in tool-green — per the color-pairing + principle, the group line CONTINUES its parent card's border, and the + spawning tool_result card's border is tool-green. (Workflow agents use + the same principle with their own grey: grey workflow_agent card → + grey side-channel line.) The :not(.sidechain) parent filter keeps the + line at the block's top level only (containers deeper inside the + sidechain hang under .sidechain-flagged cards). */ .message-node:has(> .message.tool_result:not(.sidechain)) > .children:has(> .message-node > .message.sidechain) { margin-left: 2em; - border-left: 2px solid var(--workflow-agent-color); + border-left: 2px solid var(--tool-use-color); } /* A phase's agents group — continues the phase card's dark green. */ diff --git a/dev-docs/css-classes.md b/dev-docs/css-classes.md index cd34f11d..9daadbfd 100755 --- a/dev-docs/css-classes.md +++ b/dev-docs/css-classes.md @@ -54,7 +54,7 @@ This document provides a comprehensive reference for CSS class combinations used | `pair_first` | Various | First message in a pair | | `pair_last` | Various | Last message in a pair | | `pair_middle` | Various | Middle message (never used so far) | -| `sidechain` | Various | Sub-agent (Task) message. The sidechain block under its spawning tool_result is framed by a single grey group line + 2em indent (same color/concept as a workflow agent's side-channel). | +| `sidechain` | Various | Sub-agent (Task) message. The sidechain block under its spawning tool_result is framed by a single tool-green group line + 2em indent — the line continues the spawning card's border color (same pairing principle as the workflow phase/agent group lines). | | `slash-command` | `user` | Expanded slash command prompt | | `steering` | `user` | User steering via queue operation | | `system-info` | `system` | System info level | diff --git a/dev-docs/workflows.md b/dev-docs/workflows.md index 706efd16..d7ca7501 100644 --- a/dev-docs/workflows.md +++ b/dev-docs/workflows.md @@ -203,12 +203,14 @@ Two `MessageContent` subclasses in [`models.py`](../claude_code_log/models.py): inside the ≤1280px responsive block), so the container's border-left lands at the exact x of its parent card's border — the group border reads as the card's border continuing down its subtree. Colors pair - per level: a phase card + its agents group are dark green + per level — the group line continues its parent card's border color: a + phase card + its agents group are dark green (`--workflow-phase-color`), an agent card + its side-channel group - are grey (`--workflow-agent-color`). The Workflow-level phases group - keeps its indent but draws no line (suppressed at 0px — two levels of - lines already distinguish a workflow from a standard sub-agent's - single grey sidechain line, which uses the same grey). Depth + are grey (`--workflow-agent-color`), and a standard sub-agent's + sidechain line is tool-green (continuing the spawning tool_result + card's border). The Workflow-level phases group keeps its indent but + draws no line (suppressed at 0px — two levels of lines already + distinguish a workflow from a standard sub-agent's single line). Depth accumulates through DOM nesting, so arbitrarily deep future nests (a sub-agent spawning its own sub-agents) indent with no new rules. - **Timeline** (`components/timeline.html`): dedicated diff --git a/test/__snapshots__/test_snapshot_html.ambr b/test/__snapshots__/test_snapshot_html.ambr index ce978ce4..22882325 100644 --- a/test/__snapshots__/test_snapshot_html.ambr +++ b/test/__snapshots__/test_snapshot_html.ambr @@ -1823,14 +1823,16 @@ /* A standard sub-agent's sidechain group (Task-spawned agents — sync, async, teammates): the agent's transcript hangs under the spawning - tool_result, framed by ONE grey line (the same color/concept as a - workflow agent's side-channel — an agent transcript is an agent - transcript). The :not(.sidechain) parent filter keeps the line at the - block's top level only (containers deeper inside the sidechain hang - under .sidechain-flagged cards). */ + tool_result, framed by ONE line in tool-green — per the color-pairing + principle, the group line CONTINUES its parent card's border, and the + spawning tool_result card's border is tool-green. (Workflow agents use + the same principle with their own grey: grey workflow_agent card → + grey side-channel line.) The :not(.sidechain) parent filter keeps the + line at the block's top level only (containers deeper inside the + sidechain hang under .sidechain-flagged cards). */ .message-node:has(> .message.tool_result:not(.sidechain)) > .children:has(> .message-node > .message.sidechain) { margin-left: 2em; - border-left: 2px solid var(--workflow-agent-color); + border-left: 2px solid var(--tool-use-color); } /* A phase's agents group — continues the phase card's dark green. */ @@ -8031,14 +8033,16 @@ /* A standard sub-agent's sidechain group (Task-spawned agents — sync, async, teammates): the agent's transcript hangs under the spawning - tool_result, framed by ONE grey line (the same color/concept as a - workflow agent's side-channel — an agent transcript is an agent - transcript). The :not(.sidechain) parent filter keeps the line at the - block's top level only (containers deeper inside the sidechain hang - under .sidechain-flagged cards). */ + tool_result, framed by ONE line in tool-green — per the color-pairing + principle, the group line CONTINUES its parent card's border, and the + spawning tool_result card's border is tool-green. (Workflow agents use + the same principle with their own grey: grey workflow_agent card → + grey side-channel line.) The :not(.sidechain) parent filter keeps the + line at the block's top level only (containers deeper inside the + sidechain hang under .sidechain-flagged cards). */ .message-node:has(> .message.tool_result:not(.sidechain)) > .children:has(> .message-node > .message.sidechain) { margin-left: 2em; - border-left: 2px solid var(--workflow-agent-color); + border-left: 2px solid var(--tool-use-color); } /* A phase's agents group — continues the phase card's dark green. */ @@ -16326,14 +16330,16 @@ /* A standard sub-agent's sidechain group (Task-spawned agents — sync, async, teammates): the agent's transcript hangs under the spawning - tool_result, framed by ONE grey line (the same color/concept as a - workflow agent's side-channel — an agent transcript is an agent - transcript). The :not(.sidechain) parent filter keeps the line at the - block's top level only (containers deeper inside the sidechain hang - under .sidechain-flagged cards). */ + tool_result, framed by ONE line in tool-green — per the color-pairing + principle, the group line CONTINUES its parent card's border, and the + spawning tool_result card's border is tool-green. (Workflow agents use + the same principle with their own grey: grey workflow_agent card → + grey side-channel line.) The :not(.sidechain) parent filter keeps the + line at the block's top level only (containers deeper inside the + sidechain hang under .sidechain-flagged cards). */ .message-node:has(> .message.tool_result:not(.sidechain)) > .children:has(> .message-node > .message.sidechain) { margin-left: 2em; - border-left: 2px solid var(--workflow-agent-color); + border-left: 2px solid var(--tool-use-color); } /* A phase's agents group — continues the phase card's dark green. */ @@ -22649,14 +22655,16 @@ /* A standard sub-agent's sidechain group (Task-spawned agents — sync, async, teammates): the agent's transcript hangs under the spawning - tool_result, framed by ONE grey line (the same color/concept as a - workflow agent's side-channel — an agent transcript is an agent - transcript). The :not(.sidechain) parent filter keeps the line at the - block's top level only (containers deeper inside the sidechain hang - under .sidechain-flagged cards). */ + tool_result, framed by ONE line in tool-green — per the color-pairing + principle, the group line CONTINUES its parent card's border, and the + spawning tool_result card's border is tool-green. (Workflow agents use + the same principle with their own grey: grey workflow_agent card → + grey side-channel line.) The :not(.sidechain) parent filter keeps the + line at the block's top level only (containers deeper inside the + sidechain hang under .sidechain-flagged cards). */ .message-node:has(> .message.tool_result:not(.sidechain)) > .children:has(> .message-node > .message.sidechain) { margin-left: 2em; - border-left: 2px solid var(--workflow-agent-color); + border-left: 2px solid var(--tool-use-color); } /* A phase's agents group — continues the phase card's dark green. */ @@ -29239,14 +29247,16 @@ /* A standard sub-agent's sidechain group (Task-spawned agents — sync, async, teammates): the agent's transcript hangs under the spawning - tool_result, framed by ONE grey line (the same color/concept as a - workflow agent's side-channel — an agent transcript is an agent - transcript). The :not(.sidechain) parent filter keeps the line at the - block's top level only (containers deeper inside the sidechain hang - under .sidechain-flagged cards). */ + tool_result, framed by ONE line in tool-green — per the color-pairing + principle, the group line CONTINUES its parent card's border, and the + spawning tool_result card's border is tool-green. (Workflow agents use + the same principle with their own grey: grey workflow_agent card → + grey side-channel line.) The :not(.sidechain) parent filter keeps the + line at the block's top level only (containers deeper inside the + sidechain hang under .sidechain-flagged cards). */ .message-node:has(> .message.tool_result:not(.sidechain)) > .children:has(> .message-node > .message.sidechain) { margin-left: 2em; - border-left: 2px solid var(--workflow-agent-color); + border-left: 2px solid var(--tool-use-color); } /* A phase's agents group — continues the phase card's dark green. */ @@ -35791,14 +35801,16 @@ /* A standard sub-agent's sidechain group (Task-spawned agents — sync, async, teammates): the agent's transcript hangs under the spawning - tool_result, framed by ONE grey line (the same color/concept as a - workflow agent's side-channel — an agent transcript is an agent - transcript). The :not(.sidechain) parent filter keeps the line at the - block's top level only (containers deeper inside the sidechain hang - under .sidechain-flagged cards). */ + tool_result, framed by ONE line in tool-green — per the color-pairing + principle, the group line CONTINUES its parent card's border, and the + spawning tool_result card's border is tool-green. (Workflow agents use + the same principle with their own grey: grey workflow_agent card → + grey side-channel line.) The :not(.sidechain) parent filter keeps the + line at the block's top level only (containers deeper inside the + sidechain hang under .sidechain-flagged cards). */ .message-node:has(> .message.tool_result:not(.sidechain)) > .children:has(> .message-node > .message.sidechain) { margin-left: 2em; - border-left: 2px solid var(--workflow-agent-color); + border-left: 2px solid var(--tool-use-color); } /* A phase's agents group — continues the phase card's dark green. */ @@ -42287,14 +42299,16 @@ /* A standard sub-agent's sidechain group (Task-spawned agents — sync, async, teammates): the agent's transcript hangs under the spawning - tool_result, framed by ONE grey line (the same color/concept as a - workflow agent's side-channel — an agent transcript is an agent - transcript). The :not(.sidechain) parent filter keeps the line at the - block's top level only (containers deeper inside the sidechain hang - under .sidechain-flagged cards). */ + tool_result, framed by ONE line in tool-green — per the color-pairing + principle, the group line CONTINUES its parent card's border, and the + spawning tool_result card's border is tool-green. (Workflow agents use + the same principle with their own grey: grey workflow_agent card → + grey side-channel line.) The :not(.sidechain) parent filter keeps the + line at the block's top level only (containers deeper inside the + sidechain hang under .sidechain-flagged cards). */ .message-node:has(> .message.tool_result:not(.sidechain)) > .children:has(> .message-node > .message.sidechain) { margin-left: 2em; - border-left: 2px solid var(--workflow-agent-color); + border-left: 2px solid var(--tool-use-color); } /* A phase's agents group — continues the phase card's dark green. */ From a1952ff4894eae1b43583a82726bf5da33414db6 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Thu, 11 Jun 2026 20:36:52 +0200 Subject: [PATCH 05/12] Soften the workflow phase color to #3a7d3c The near-black green (#1b5e20) read too heavy against the pastel theme; #3a7d3c keeps the phase level clearly darker than tool-green while sitting better in the palette. One variable drives both the phase card border and its agents group line; the runtime-CSS contract test pins the new value. Snapshots CSS-only (regenerated serially). Co-Authored-By: Claude Fable 5 --- .../html/templates/components/global_styles.css | 2 +- test/__snapshots__/test_snapshot_html.ambr | 16 ++++++++-------- test/test_workflow_browser.py | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/claude_code_log/html/templates/components/global_styles.css b/claude_code_log/html/templates/components/global_styles.css index a6a06890..3e3d8616 100644 --- a/claude_code_log/html/templates/components/global_styles.css +++ b/claude_code_log/html/templates/components/global_styles.css @@ -40,7 +40,7 @@ /* Dynamic-workflow tree colors (#174): each level's card border and its group border (the line framing the card's subtree) share a color. */ - --workflow-phase-color: #1b5e20; + --workflow-phase-color: #3a7d3c; --workflow-agent-color: #9e9e9e; /* Fork/branch structural colors */ diff --git a/test/__snapshots__/test_snapshot_html.ambr b/test/__snapshots__/test_snapshot_html.ambr index 22882325..e0d8b3b6 100644 --- a/test/__snapshots__/test_snapshot_html.ambr +++ b/test/__snapshots__/test_snapshot_html.ambr @@ -52,7 +52,7 @@ /* Dynamic-workflow tree colors (#174): each level's card border and its group border (the line framing the card's subtree) share a color. */ - --workflow-phase-color: #1b5e20; + --workflow-phase-color: #3a7d3c; --workflow-agent-color: #9e9e9e; /* Fork/branch structural colors */ @@ -6262,7 +6262,7 @@ /* Dynamic-workflow tree colors (#174): each level's card border and its group border (the line framing the card's subtree) share a color. */ - --workflow-phase-color: #1b5e20; + --workflow-phase-color: #3a7d3c; --workflow-agent-color: #9e9e9e; /* Fork/branch structural colors */ @@ -12371,7 +12371,7 @@ /* Dynamic-workflow tree colors (#174): each level's card border and its group border (the line framing the card's subtree) share a color. */ - --workflow-phase-color: #1b5e20; + --workflow-phase-color: #3a7d3c; --workflow-agent-color: #9e9e9e; /* Fork/branch structural colors */ @@ -14559,7 +14559,7 @@ /* Dynamic-workflow tree colors (#174): each level's card border and its group border (the line framing the card's subtree) share a color. */ - --workflow-phase-color: #1b5e20; + --workflow-phase-color: #3a7d3c; --workflow-agent-color: #9e9e9e; /* Fork/branch structural colors */ @@ -20884,7 +20884,7 @@ /* Dynamic-workflow tree colors (#174): each level's card border and its group border (the line framing the card's subtree) share a color. */ - --workflow-phase-color: #1b5e20; + --workflow-phase-color: #3a7d3c; --workflow-agent-color: #9e9e9e; /* Fork/branch structural colors */ @@ -27476,7 +27476,7 @@ /* Dynamic-workflow tree colors (#174): each level's card border and its group border (the line framing the card's subtree) share a color. */ - --workflow-phase-color: #1b5e20; + --workflow-phase-color: #3a7d3c; --workflow-agent-color: #9e9e9e; /* Fork/branch structural colors */ @@ -34030,7 +34030,7 @@ /* Dynamic-workflow tree colors (#174): each level's card border and its group border (the line framing the card's subtree) share a color. */ - --workflow-phase-color: #1b5e20; + --workflow-phase-color: #3a7d3c; --workflow-agent-color: #9e9e9e; /* Fork/branch structural colors */ @@ -40528,7 +40528,7 @@ /* Dynamic-workflow tree colors (#174): each level's card border and its group border (the line framing the card's subtree) share a color. */ - --workflow-phase-color: #1b5e20; + --workflow-phase-color: #3a7d3c; --workflow-agent-color: #9e9e9e; /* Fork/branch structural colors */ diff --git a/test/test_workflow_browser.py b/test/test_workflow_browser.py index d0a4bd5b..08c5e8eb 100644 --- a/test/test_workflow_browser.py +++ b/test/test_workflow_browser.py @@ -133,7 +133,7 @@ def test_group_borders_and_alignment(self, page: Page, tmp_path: Path) -> None: # Workflow-level group line is suppressed; phase + agent lines drawn. assert result["phasesGroup"]["bw"] == "0px" assert result["agentsGroup"]["bw"] == "2px" - assert result["agentsGroup"]["bc"] == "rgb(27, 94, 32)" # #1b5e20 + assert result["agentsGroup"]["bc"] == "rgb(58, 125, 60)" # #3a7d3c assert result["scGroup"]["bw"] == "2px" assert result["scGroup"]["bc"] == "rgb(158, 158, 158)" # #9e9e9e # Each group border continues its parent card's border (same x). From d7f36e0d9991b0e27eccaccef445a9906b4b478e Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Thu, 11 Jun 2026 21:01:23 +0200 Subject: [PATCH 06/12] Params folds: key-column toggles + no interactive elements in summaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two intertwined refinements to the hybrid params tables, from rendering review: ACCESSIBILITY: Chrome flagged every rows-toggle button as an interactive element inside (not consistently reachable by keyboard/AT — hundreds of identical DevTools errors on a large page). The button moves to a controls strip AFTER the summary, inside the details (natively hidden while closed); summaries now hold only the preview text. The remaining handful of link-in-summary notices come from markdown-rendered preview content elsewhere — out of scope here. VISUAL WEIGHT: every open fold showing an in-summary '▼ collapse' drowned the content. Fold-valued rows now hoist their toggle into the KEY column: - The whole key (glyph + text) is one + {preview} +
{table_html} """ @@ -1173,15 +1177,40 @@ def _param_value_html(value: Any, depth: int) -> str: def _params_table_html(items: "Iterable[tuple[Any, Any]]", depth: int) -> str: - """Build one key/value table; nested levels get a marker class.""" + """Build one key/value table; nested levels get a marker class. + + A fold-valued row hoists its ▶/▼ toggle into the KEY column (wired in + transcript.html), so the value summary stays free of per-fold collapse + chrome — only the previews (closed) and the rows-toggle buttons (open) + remain there. The ``"] for key, value in items: escaped_key = escape_html(str(key)) value_html = _param_value_html(value, depth) + if value_html.lstrip().startswith("
assistant_00 → assistant_00
- - + +
todos
[ @@ -32249,15 +32559,16 @@ { "id": "2", "content": "Implement core functionality", - "status": "...collapse + "status": "... +
- + - - + + - - + + - - + + - - + + " in html - assert "" in html + # Scalar rows: glyph spacer + index in the key cell. + assert "0" in html + assert "1" in html assert "alpha" in html and "beta" in html def test_structures_always_fold_regardless_of_size(self): @@ -141,15 +142,19 @@ def test_long_structure_folds_with_json_preview(self): assert "tool-params-nested" in html def test_table_fold_carries_rows_toggle(self): - """Structured-table folds with foldable rows get the explicit - hint + rows-toggle button; plain string folds and the JSON - fallback do not.""" + """Structured-table folds with foldable rows get the rows-toggle + button in a controls strip AFTER the summary (interactive elements + inside are an accessibility violation); plain string + folds and the JSON fallback do not.""" value = {f"key_{i}": {"nested": i} for i in range(5)} html = render_params_table({"cfg": value}) assert "tool-param-collapsible-rows" in html - assert "tool-param-collapse-hint" in html + assert "tool-param-fold-controls" in html assert "tool-param-rows-toggle" in html assert "expand all properties" in html + # The summary holds only the preview — no interactive children. + summary = html.split("", 1)[1].split("", 1)[0] + assert "" + "count" in html + ) + def test_empty_containers_fall_back_to_json_dump(self): html = render_params_table({"a": {}, "b": []}) assert html.count("tool-param-structured") == 2 @@ -280,8 +303,11 @@ def test_array_result_renders_indexed_table(self): result = _tool_result('[{"id": 1}, {"id": 2}]') html = format_tool_result_content_raw(result) assert "tool-result-json" in html - assert "" in html - assert "" in html + # Index keys head their rows; these rows hold dict folds, so the + # key cell wraps the index in the key-toggle button. + assert "0" in html + assert "1" in html + assert "tool-param-key-toggle" in html def test_invalid_json_stays_text(self): result = _tool_result('{"status": "ok", trailing') From fbbf3c29e92e70bbec2700a97fae3f164f9c9d0d Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Thu, 11 Jun 2026 21:19:20 +0200 Subject: [PATCH 07/12] =?UTF-8?q?Unify=20the=20fold=20glyphs:=20centered?= =?UTF-8?q?=20=E2=96=B8=20rotated=20in=20place,=20on=20all=20three=20contr?= =?UTF-8?q?ols?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rendering review follow-ups on the key-column toggles: - The rotation pivoted around the 1.3em slot's box center while the glyph sat at its left edge, so opening visibly swung the arrow sideways. Centering the glyph in its slot makes the box-center rotation pivot on the glyph itself — it now rotates in place (measured zero center shift). - The expand-all and rows-toggle buttons still used the mismatched ▶/▼ text pair. All three control types now share the same structure — a constant ▸ in a .tool-param-fold-glyph slot (CSS-rotated on data-state/aria-expanded) plus a .tool-param-fold-label — and the sync functions update only the label text. One glyph class, one rotation rule, symmetric everywhere. Co-Authored-By: Claude Fable 5 --- .../templates/components/message_styles.css | 14 +- .../html/templates/transcript.html | 14 +- claude_code_log/html/tool_formatters.py | 10 +- test/__snapshots__/test_snapshot_html.ambr | 246 ++++++++++++------ test/test_params_table_hybrid.py | 6 +- 5 files changed, 194 insertions(+), 96 deletions(-) diff --git a/claude_code_log/html/templates/components/message_styles.css b/claude_code_log/html/templates/components/message_styles.css index 0ab98cd1..d5d84556 100644 --- a/claude_code_log/html/templates/components/message_styles.css +++ b/claude_code_log/html/templates/components/message_styles.css @@ -1060,16 +1060,22 @@ pre > code { color: var(--text-primary); } -/* Fixed glyph slot in EVERY key cell (empty spacer on scalar rows), so - all key texts start at the same x regardless of foldability. */ -.tool-param-key-glyph { +/* The shared fold glyph: one constant ▸, CSS-rotated 90° when its control + is expanded — symmetric open/closed by construction. Centered in a fixed + slot so the box-center rotation pivots on the glyph itself (a left- + aligned glyph in a wide box swings sideways when the box rotates). The + slot doubles as the key-column alignment spacer: every key cell renders + one (empty on scalar rows), so all key texts start at the same x. */ +.tool-param-fold-glyph { display: inline-block; width: 1.3em; + text-align: center; color: var(--text-muted); transition: transform 0.15s ease; } -.tool-param-key-toggle[aria-expanded='true'] > .tool-param-key-glyph { +.tool-param-key-toggle[aria-expanded='true'] > .tool-param-fold-glyph, +[data-state='expanded'] > .tool-param-fold-glyph { transform: rotate(90deg); } diff --git a/claude_code_log/html/templates/transcript.html b/claude_code_log/html/templates/transcript.html index f35c9088..1c8a38c2 100644 --- a/claude_code_log/html/templates/transcript.html +++ b/claude_code_log/html/templates/transcript.html @@ -427,10 +427,13 @@

🔍 Search & Filter

const allOpen = rows.length > 0 && Array.from(rows).every(row => row.open); const kind = button.dataset.kind || 'rows'; + // data-state drives the CSS rotation of the constant ▸ glyph; + // only the label text changes. button.dataset.state = allOpen ? 'expanded' : 'collapsed'; - button.textContent = allOpen - ? '▼ collapse all ' + kind - : '▶ expand all ' + kind; + const label = button.querySelector('.tool-param-fold-label'); + if (label) { + label.textContent = (allOpen ? 'collapse all ' : 'expand all ') + kind; + } } // Top-level expand-all for a whole params/result renderer: @@ -443,7 +446,10 @@

🔍 Search & Filter

const allOpen = folds.length > 0 && Array.from(folds).every(fold => fold.open); button.dataset.state = allOpen ? 'expanded' : 'collapsed'; - button.textContent = allOpen ? '▼ collapse all' : '▶ expand all'; + const label = button.querySelector('.tool-param-fold-label'); + if (label) { + label.textContent = allOpen ? 'collapse all' : 'expand all'; + } } document.addEventListener('click', function (event) { diff --git a/claude_code_log/html/tool_formatters.py b/claude_code_log/html/tool_formatters.py index b63c940d..0ab231b0 100644 --- a/claude_code_log/html/tool_formatters.py +++ b/claude_code_log/html/tool_formatters.py @@ -1133,7 +1133,7 @@ def _table_fold_html(formatted_value: str, table_html: str, kind: str) -> str: return f"""
{preview} -
+
{table_html}
""" @@ -1159,7 +1159,9 @@ def _params_root_html(table_html: str) -> str: "
" "
" "" + " data-state='collapsed'>" + "" + "expand all" "
" f"{table_html}" "
" @@ -1199,14 +1201,14 @@ def _params_table_html(items: "Iterable[tuple[Any, Any]]", depth: int) -> str: key_cell = ( "" ) row_attr = " class='tool-param-row-fold'" else: # Scalar rows reserve the same glyph slot (empty) so every key # text starts at the same x. - key_cell = f"{escaped_key}" + key_cell = f"{escaped_key}" row_attr = "" html_parts.append(f""" diff --git a/test/__snapshots__/test_snapshot_html.ambr b/test/__snapshots__/test_snapshot_html.ambr index 8c952f7e..69f02ffc 100644 --- a/test/__snapshots__/test_snapshot_html.ambr +++ b/test/__snapshots__/test_snapshot_html.ambr @@ -1419,16 +1419,22 @@ color: var(--text-primary); } - /* Fixed glyph slot in EVERY key cell (empty spacer on scalar rows), so - all key texts start at the same x regardless of foldability. */ - .tool-param-key-glyph { + /* The shared fold glyph: one constant ▸, CSS-rotated 90° when its control + is expanded — symmetric open/closed by construction. Centered in a fixed + slot so the box-center rotation pivots on the glyph itself (a left- + aligned glyph in a wide box swings sideways when the box rotates). The + slot doubles as the key-column alignment spacer: every key cell renders + one (empty on scalar rows), so all key texts start at the same x. */ + .tool-param-fold-glyph { display: inline-block; width: 1.3em; + text-align: center; color: var(--text-muted); transition: transform 0.15s ease; } - .tool-param-key-toggle[aria-expanded='true'] > .tool-param-key-glyph { + .tool-param-key-toggle[aria-expanded='true'] > .tool-param-fold-glyph, + [data-state='expanded'] > .tool-param-fold-glyph { transform: rotate(90deg); } @@ -5006,10 +5012,13 @@ const allOpen = rows.length > 0 && Array.from(rows).every(row => row.open); const kind = button.dataset.kind || 'rows'; + // data-state drives the CSS rotation of the constant ▸ glyph; + // only the label text changes. button.dataset.state = allOpen ? 'expanded' : 'collapsed'; - button.textContent = allOpen - ? '▼ collapse all ' + kind - : '▶ expand all ' + kind; + const label = button.querySelector('.tool-param-fold-label'); + if (label) { + label.textContent = (allOpen ? 'collapse all ' : 'expand all ') + kind; + } } // Top-level expand-all for a whole params/result renderer: @@ -5022,7 +5031,10 @@ const allOpen = folds.length > 0 && Array.from(folds).every(fold => fold.open); button.dataset.state = allOpen ? 'expanded' : 'collapsed'; - button.textContent = allOpen ? '▼ collapse all' : '▶ expand all'; + const label = button.querySelector('.tool-param-fold-label'); + if (label) { + label.textContent = allOpen ? 'collapse all' : 'expand all'; + } } document.addEventListener('click', function (event) { @@ -7696,16 +7708,22 @@ color: var(--text-primary); } - /* Fixed glyph slot in EVERY key cell (empty spacer on scalar rows), so - all key texts start at the same x regardless of foldability. */ - .tool-param-key-glyph { + /* The shared fold glyph: one constant ▸, CSS-rotated 90° when its control + is expanded — symmetric open/closed by construction. Centered in a fixed + slot so the box-center rotation pivots on the glyph itself (a left- + aligned glyph in a wide box swings sideways when the box rotates). The + slot doubles as the key-column alignment spacer: every key cell renders + one (empty on scalar rows), so all key texts start at the same x. */ + .tool-param-fold-glyph { display: inline-block; width: 1.3em; + text-align: center; color: var(--text-muted); transition: transform 0.15s ease; } - .tool-param-key-toggle[aria-expanded='true'] > .tool-param-key-glyph { + .tool-param-key-toggle[aria-expanded='true'] > .tool-param-fold-glyph, + [data-state='expanded'] > .tool-param-fold-glyph { transform: rotate(90deg); } @@ -11182,10 +11200,13 @@ const allOpen = rows.length > 0 && Array.from(rows).every(row => row.open); const kind = button.dataset.kind || 'rows'; + // data-state drives the CSS rotation of the constant ▸ glyph; + // only the label text changes. button.dataset.state = allOpen ? 'expanded' : 'collapsed'; - button.textContent = allOpen - ? '▼ collapse all ' + kind - : '▶ expand all ' + kind; + const label = button.querySelector('.tool-param-fold-label'); + if (label) { + label.textContent = (allOpen ? 'collapse all ' : 'expand all ') + kind; + } } // Top-level expand-all for a whole params/result renderer: @@ -11198,7 +11219,10 @@ const allOpen = folds.length > 0 && Array.from(folds).every(fold => fold.open); button.dataset.state = allOpen ? 'expanded' : 'collapsed'; - button.textContent = allOpen ? '▼ collapse all' : '▶ expand all'; + const label = button.querySelector('.tool-param-fold-label'); + if (label) { + label.textContent = allOpen ? 'collapse all' : 'expand all'; + } } document.addEventListener('click', function (event) { @@ -16060,16 +16084,22 @@ color: var(--text-primary); } - /* Fixed glyph slot in EVERY key cell (empty spacer on scalar rows), so - all key texts start at the same x regardless of foldability. */ - .tool-param-key-glyph { + /* The shared fold glyph: one constant ▸, CSS-rotated 90° when its control + is expanded — symmetric open/closed by construction. Centered in a fixed + slot so the box-center rotation pivots on the glyph itself (a left- + aligned glyph in a wide box swings sideways when the box rotates). The + slot doubles as the key-column alignment spacer: every key cell renders + one (empty on scalar rows), so all key texts start at the same x. */ + .tool-param-fold-glyph { display: inline-block; width: 1.3em; + text-align: center; color: var(--text-muted); transition: transform 0.15s ease; } - .tool-param-key-toggle[aria-expanded='true'] > .tool-param-key-glyph { + .tool-param-key-toggle[aria-expanded='true'] > .tool-param-fold-glyph, + [data-state='expanded'] > .tool-param-fold-glyph { transform: rotate(90deg); } @@ -19762,10 +19792,13 @@ const allOpen = rows.length > 0 && Array.from(rows).every(row => row.open); const kind = button.dataset.kind || 'rows'; + // data-state drives the CSS rotation of the constant ▸ glyph; + // only the label text changes. button.dataset.state = allOpen ? 'expanded' : 'collapsed'; - button.textContent = allOpen - ? '▼ collapse all ' + kind - : '▶ expand all ' + kind; + const label = button.querySelector('.tool-param-fold-label'); + if (label) { + label.textContent = (allOpen ? 'collapse all ' : 'expand all ') + kind; + } } // Top-level expand-all for a whole params/result renderer: @@ -19778,7 +19811,10 @@ const allOpen = folds.length > 0 && Array.from(folds).every(fold => fold.open); button.dataset.state = allOpen ? 'expanded' : 'collapsed'; - button.textContent = allOpen ? '▼ collapse all' : '▶ expand all'; + const label = button.querySelector('.tool-param-fold-label'); + if (label) { + label.textContent = allOpen ? 'collapse all' : 'expand all'; + } } document.addEventListener('click', function (event) { @@ -22452,16 +22488,22 @@ color: var(--text-primary); } - /* Fixed glyph slot in EVERY key cell (empty spacer on scalar rows), so - all key texts start at the same x regardless of foldability. */ - .tool-param-key-glyph { + /* The shared fold glyph: one constant ▸, CSS-rotated 90° when its control + is expanded — symmetric open/closed by construction. Centered in a fixed + slot so the box-center rotation pivots on the glyph itself (a left- + aligned glyph in a wide box swings sideways when the box rotates). The + slot doubles as the key-column alignment spacer: every key cell renders + one (empty on scalar rows), so all key texts start at the same x. */ + .tool-param-fold-glyph { display: inline-block; width: 1.3em; + text-align: center; color: var(--text-muted); transition: transform 0.15s ease; } - .tool-param-key-toggle[aria-expanded='true'] > .tool-param-key-glyph { + .tool-param-key-toggle[aria-expanded='true'] > .tool-param-fold-glyph, + [data-state='expanded'] > .tool-param-fold-glyph { transform: rotate(90deg); } @@ -26421,10 +26463,13 @@ const allOpen = rows.length > 0 && Array.from(rows).every(row => row.open); const kind = button.dataset.kind || 'rows'; + // data-state drives the CSS rotation of the constant ▸ glyph; + // only the label text changes. button.dataset.state = allOpen ? 'expanded' : 'collapsed'; - button.textContent = allOpen - ? '▼ collapse all ' + kind - : '▶ expand all ' + kind; + const label = button.querySelector('.tool-param-fold-label'); + if (label) { + label.textContent = (allOpen ? 'collapse all ' : 'expand all ') + kind; + } } // Top-level expand-all for a whole params/result renderer: @@ -26437,7 +26482,10 @@ const allOpen = folds.length > 0 && Array.from(folds).every(fold => fold.open); button.dataset.state = allOpen ? 'expanded' : 'collapsed'; - button.textContent = allOpen ? '▼ collapse all' : '▶ expand all'; + const label = button.querySelector('.tool-param-fold-label'); + if (label) { + label.textContent = allOpen ? 'collapse all' : 'expand all'; + } } document.addEventListener('click', function (event) { @@ -29111,16 +29159,22 @@ color: var(--text-primary); } - /* Fixed glyph slot in EVERY key cell (empty spacer on scalar rows), so - all key texts start at the same x regardless of foldability. */ - .tool-param-key-glyph { + /* The shared fold glyph: one constant ▸, CSS-rotated 90° when its control + is expanded — symmetric open/closed by construction. Centered in a fixed + slot so the box-center rotation pivots on the glyph itself (a left- + aligned glyph in a wide box swings sideways when the box rotates). The + slot doubles as the key-column alignment spacer: every key cell renders + one (empty on scalar rows), so all key texts start at the same x. */ + .tool-param-fold-glyph { display: inline-block; width: 1.3em; + text-align: center; color: var(--text-muted); transition: transform 0.15s ease; } - .tool-param-key-toggle[aria-expanded='true'] > .tool-param-key-glyph { + .tool-param-key-toggle[aria-expanded='true'] > .tool-param-fold-glyph, + [data-state='expanded'] > .tool-param-fold-glyph { transform: rotate(90deg); } @@ -32303,7 +32357,7 @@
edge_004
00 broken_todo
1
{ @@ -32267,22 +32578,22 @@ "priority":... - + - + - + - +
idid 2
contentcontent Implement core functionality
statusstatus in_progress
prioritypriority high
@@ -32290,8 +32601,8 @@
2
{ @@ -32301,22 +32612,22 @@ "priority": "medium"... - + - + - + - +
idid 3
contentcontent Add comprehensive tests
statusstatus pending
prioritypriority medium
@@ -32324,8 +32635,8 @@
3
{ @@ -32336,22 +32647,22 @@ } - + - + - + - +
idid 4
contentcontent Write user documentation
statusstatus pending
prioritypriority low
@@ -32359,8 +32670,8 @@
4
{ @@ -32371,22 +32682,22 @@ } - + - + - + - +
idid 5
contentcontent Perform code review
statusstatus pending
prioritypriority medium
@@ -32723,7 +33034,10 @@ // it stays in sync whatever opened the rows (button click, // toggle-all, manual clicks). function syncRowsToggle(details) { - const button = details.querySelector(':scope > summary .tool-param-rows-toggle'); + // The button lives in a controls strip AFTER the summary — + // interactive elements inside are an accessibility + // violation (unreachable by keyboard/AT in some browsers). + const button = details.querySelector(':scope > .tool-param-fold-controls > .tool-param-rows-toggle'); if (!button) return; const rows = details.querySelectorAll(rowDetailsSelector); const allOpen = rows.length > 0 && @@ -32759,12 +33073,20 @@ syncExpandAll(root); return; } + // Key-column fold toggle (▶/▼): drives the value cell's + // details from the key cell, keeping the summary free of + // per-fold collapse chrome. Glyph state is derived in the + // toggle listener below, so any other opener stays in sync. + const keyToggle = event.target.closest('.tool-param-key-toggle'); + if (keyToggle) { + const row = keyToggle.closest('tr'); + const details = row && + row.querySelector(':scope > .tool-param-value > details'); + if (details) details.open = !details.open; + return; + } const button = event.target.closest('.tool-param-rows-toggle'); if (!button) return; - // The button sits inside a : keep the click from - // toggling the enclosing details. - event.preventDefault(); - event.stopPropagation(); const details = button.closest('details'); const expand = button.dataset.state !== 'expanded'; details.querySelectorAll(rowDetailsSelector).forEach(row => { @@ -32791,6 +33113,20 @@ const cell = details.parentElement; const parent = cell ? cell.closest('details.tool-param-collapsible-rows') : null; if (parent && parent !== details) syncRowsToggle(parent); + // Sync the key-column glyph of a fold-valued row — derived + // from the actual open state, so summary clicks, rows-toggle + // bulk opens, and expand-all all keep it truthful. + if (cell && cell.classList && cell.classList.contains('tool-param-value')) { + const row = cell.closest('tr'); + const keyBtn = row && row.querySelector( + ':scope > .tool-param-key > .tool-param-key-toggle'); + if (keyBtn) { + // CSS rotates the single ▸ glyph on aria-expanded — + // symmetric open/closed states by construction. + keyBtn.setAttribute('aria-expanded', + details.open ? 'true' : 'false'); + } + } // Keep the renderer's top-level expand-all button in step // with whatever opened/closed this fold (rows-toggle, // manual click, global toggle-all). @@ -35351,40 +35687,82 @@ word-break: break-all; } - /* Structured-table folds carry an explicit collapse hint plus a - rows-toggle button in the summary, so suppress the generic ::after - "collapse" hint for them and show the real elements only when open. */ - .tool-param-collapsible-rows[open] > summary::after { - content: none; + /* The rows-toggle lives in a controls strip AFTER the summary — never + inside it (an interactive element within is an accessibility + violation: not consistently reachable by keyboard/AT). The strip is + details content, so it's natively hidden while the fold is closed. */ + .tool-param-fold-controls { + margin: 2px 0; } - .tool-param-collapse-hint, .tool-param-rows-toggle { - display: none; - } - - .tool-param-collapsible-rows[open] > summary > .tool-param-collapse-hint { - display: inline; + padding: 0; + background: none; + border: none; + font: inherit; color: var(--text-muted); font-style: italic; + cursor: pointer; } - .tool-param-collapsible-rows[open] > summary > .tool-param-rows-toggle { - display: inline; - margin-left: 1.5em; + .tool-param-rows-toggle:hover { + color: var(--text-primary); + } + + /* Fold-valued rows hoist their toggle into the KEY column: the key cell's + ▶/▼ button (wired in transcript.html, state derived from toggle events) + replaces the per-fold in-summary collapse chrome, which only remained + useful en masse — every row showing "▼ collapse" drowned the content. + Scoped to keyed rows: the top-level root fold has no key column and + keeps its summary affordances (the pure-CSS ::after "collapse" hint — + no interactive child). */ + .tool-param-key-toggle { + /* The whole key (glyph + text) is the click target — inherit the key + cell's look so fold keys read exactly like scalar keys. */ padding: 0; background: none; border: none; font: inherit; - color: var(--text-muted); - font-style: italic; + color: inherit; cursor: pointer; + text-align: left; + white-space: nowrap; } - .tool-param-rows-toggle:hover { + .tool-param-key-toggle:hover { color: var(--text-primary); } + /* Fixed glyph slot in EVERY key cell (empty spacer on scalar rows), so + all key texts start at the same x regardless of foldability. */ + .tool-param-key-glyph { + display: inline-block; + width: 1.3em; + color: var(--text-muted); + transition: transform 0.15s ease; + } + + .tool-param-key-toggle[aria-expanded='true'] > .tool-param-key-glyph { + transform: rotate(90deg); + } + + /* The key glyph is THE marker for these rows — drop the duplicate native + one from the value summary (preview text stays clickable). */ + .tool-param-row-fold > .tool-param-value > details > summary { + list-style: none; + } + + .tool-param-row-fold > .tool-param-value > details > summary::-webkit-details-marker { + display: none; + } + + /* A keyed fold's open summary carries nothing (the key glyph shows ▼ and + the rows-toggle sits in the controls strip below) — drop the line it + would occupy. This also suppresses the generic ::after "collapse". */ + .tool-param-row-fold > .tool-param-value > details[open] > summary { + display: none; + } + /* Top-level expand/collapse-all control above a params/result table — same quiet look as the in-summary rows-toggle. */ .tool-params-controls { @@ -39221,7 +39599,10 @@ // it stays in sync whatever opened the rows (button click, // toggle-all, manual clicks). function syncRowsToggle(details) { - const button = details.querySelector(':scope > summary .tool-param-rows-toggle'); + // The button lives in a controls strip AFTER the summary — + // interactive elements inside are an accessibility + // violation (unreachable by keyboard/AT in some browsers). + const button = details.querySelector(':scope > .tool-param-fold-controls > .tool-param-rows-toggle'); if (!button) return; const rows = details.querySelectorAll(rowDetailsSelector); const allOpen = rows.length > 0 && @@ -39257,12 +39638,20 @@ syncExpandAll(root); return; } + // Key-column fold toggle (▶/▼): drives the value cell's + // details from the key cell, keeping the summary free of + // per-fold collapse chrome. Glyph state is derived in the + // toggle listener below, so any other opener stays in sync. + const keyToggle = event.target.closest('.tool-param-key-toggle'); + if (keyToggle) { + const row = keyToggle.closest('tr'); + const details = row && + row.querySelector(':scope > .tool-param-value > details'); + if (details) details.open = !details.open; + return; + } const button = event.target.closest('.tool-param-rows-toggle'); if (!button) return; - // The button sits inside a : keep the click from - // toggling the enclosing details. - event.preventDefault(); - event.stopPropagation(); const details = button.closest('details'); const expand = button.dataset.state !== 'expanded'; details.querySelectorAll(rowDetailsSelector).forEach(row => { @@ -39289,6 +39678,20 @@ const cell = details.parentElement; const parent = cell ? cell.closest('details.tool-param-collapsible-rows') : null; if (parent && parent !== details) syncRowsToggle(parent); + // Sync the key-column glyph of a fold-valued row — derived + // from the actual open state, so summary clicks, rows-toggle + // bulk opens, and expand-all all keep it truthful. + if (cell && cell.classList && cell.classList.contains('tool-param-value')) { + const row = cell.closest('tr'); + const keyBtn = row && row.querySelector( + ':scope > .tool-param-key > .tool-param-key-toggle'); + if (keyBtn) { + // CSS rotates the single ▸ glyph on aria-expanded — + // symmetric open/closed states by construction. + keyBtn.setAttribute('aria-expanded', + details.open ? 'true' : 'false'); + } + } // Keep the renderer's top-level expand-all button in step // with whatever opened/closed this fold (rows-toggle, // manual click, global toggle-all). @@ -41849,40 +42252,82 @@ word-break: break-all; } - /* Structured-table folds carry an explicit collapse hint plus a - rows-toggle button in the summary, so suppress the generic ::after - "collapse" hint for them and show the real elements only when open. */ - .tool-param-collapsible-rows[open] > summary::after { - content: none; + /* The rows-toggle lives in a controls strip AFTER the summary — never + inside it (an interactive element within is an accessibility + violation: not consistently reachable by keyboard/AT). The strip is + details content, so it's natively hidden while the fold is closed. */ + .tool-param-fold-controls { + margin: 2px 0; } - .tool-param-collapse-hint, .tool-param-rows-toggle { - display: none; - } - - .tool-param-collapsible-rows[open] > summary > .tool-param-collapse-hint { - display: inline; + padding: 0; + background: none; + border: none; + font: inherit; color: var(--text-muted); font-style: italic; + cursor: pointer; } - .tool-param-collapsible-rows[open] > summary > .tool-param-rows-toggle { - display: inline; - margin-left: 1.5em; + .tool-param-rows-toggle:hover { + color: var(--text-primary); + } + + /* Fold-valued rows hoist their toggle into the KEY column: the key cell's + ▶/▼ button (wired in transcript.html, state derived from toggle events) + replaces the per-fold in-summary collapse chrome, which only remained + useful en masse — every row showing "▼ collapse" drowned the content. + Scoped to keyed rows: the top-level root fold has no key column and + keeps its summary affordances (the pure-CSS ::after "collapse" hint — + no interactive child). */ + .tool-param-key-toggle { + /* The whole key (glyph + text) is the click target — inherit the key + cell's look so fold keys read exactly like scalar keys. */ padding: 0; background: none; border: none; font: inherit; - color: var(--text-muted); - font-style: italic; + color: inherit; cursor: pointer; + text-align: left; + white-space: nowrap; } - .tool-param-rows-toggle:hover { + .tool-param-key-toggle:hover { color: var(--text-primary); } + /* Fixed glyph slot in EVERY key cell (empty spacer on scalar rows), so + all key texts start at the same x regardless of foldability. */ + .tool-param-key-glyph { + display: inline-block; + width: 1.3em; + color: var(--text-muted); + transition: transform 0.15s ease; + } + + .tool-param-key-toggle[aria-expanded='true'] > .tool-param-key-glyph { + transform: rotate(90deg); + } + + /* The key glyph is THE marker for these rows — drop the duplicate native + one from the value summary (preview text stays clickable). */ + .tool-param-row-fold > .tool-param-value > details > summary { + list-style: none; + } + + .tool-param-row-fold > .tool-param-value > details > summary::-webkit-details-marker { + display: none; + } + + /* A keyed fold's open summary carries nothing (the key glyph shows ▼ and + the rows-toggle sits in the controls strip below) — drop the line it + would occupy. This also suppresses the generic ::after "collapse". */ + .tool-param-row-fold > .tool-param-value > details[open] > summary { + display: none; + } + /* Top-level expand/collapse-all control above a params/result table — same quiet look as the in-summary rows-toggle. */ .tool-params-controls { @@ -45546,7 +45991,10 @@ // it stays in sync whatever opened the rows (button click, // toggle-all, manual clicks). function syncRowsToggle(details) { - const button = details.querySelector(':scope > summary .tool-param-rows-toggle'); + // The button lives in a controls strip AFTER the summary — + // interactive elements inside are an accessibility + // violation (unreachable by keyboard/AT in some browsers). + const button = details.querySelector(':scope > .tool-param-fold-controls > .tool-param-rows-toggle'); if (!button) return; const rows = details.querySelectorAll(rowDetailsSelector); const allOpen = rows.length > 0 && @@ -45582,12 +46030,20 @@ syncExpandAll(root); return; } + // Key-column fold toggle (▶/▼): drives the value cell's + // details from the key cell, keeping the summary free of + // per-fold collapse chrome. Glyph state is derived in the + // toggle listener below, so any other opener stays in sync. + const keyToggle = event.target.closest('.tool-param-key-toggle'); + if (keyToggle) { + const row = keyToggle.closest('tr'); + const details = row && + row.querySelector(':scope > .tool-param-value > details'); + if (details) details.open = !details.open; + return; + } const button = event.target.closest('.tool-param-rows-toggle'); if (!button) return; - // The button sits inside a : keep the click from - // toggling the enclosing details. - event.preventDefault(); - event.stopPropagation(); const details = button.closest('details'); const expand = button.dataset.state !== 'expanded'; details.querySelectorAll(rowDetailsSelector).forEach(row => { @@ -45614,6 +46070,20 @@ const cell = details.parentElement; const parent = cell ? cell.closest('details.tool-param-collapsible-rows') : null; if (parent && parent !== details) syncRowsToggle(parent); + // Sync the key-column glyph of a fold-valued row — derived + // from the actual open state, so summary clicks, rows-toggle + // bulk opens, and expand-all all keep it truthful. + if (cell && cell.classList && cell.classList.contains('tool-param-value')) { + const row = cell.closest('tr'); + const keyBtn = row && row.querySelector( + ':scope > .tool-param-key > .tool-param-key-toggle'); + if (keyBtn) { + // CSS rotates the single ▸ glyph on aria-expanded — + // symmetric open/closed states by construction. + keyBtn.setAttribute('aria-expanded', + details.open ? 'true' : 'false'); + } + } // Keep the renderer's top-level expand-all button in step // with whatever opened/closed this fold (rows-toggle, // manual click, global toggle-all). diff --git a/test/test_params_rows_toggle_browser.py b/test/test_params_rows_toggle_browser.py index a9acdfe3..a6aa0f28 100644 --- a/test/test_params_rows_toggle_browser.py +++ b/test/test_params_rows_toggle_browser.py @@ -1,9 +1,12 @@ -"""Playwright tests for the params-table rows-toggle button. - -An open structured-value fold shows "▶ expand rows" after the collapse -hint; pressing it opens every row-level fold of that table and turns -into "▼ collapse rows"; closing the outer fold restores the initial -state (rows collapsed, button reset). +"""Playwright tests for the params-table fold controls. + +An open structured-value fold shows "▶ expand all rows" in a controls +strip after the summary (never inside it — interactive elements within + are an accessibility violation); pressing it opens every +row-level fold of that table and turns into "▼ collapse all rows"; +closing the outer fold restores the initial state. Fold-valued rows +carry their ▶/▼ toggle in the KEY column, derived from the actual open +state. """ from __future__ import annotations @@ -102,7 +105,9 @@ def test_rows_toggle_cycle(self, page: Page) -> None: page.goto(f"file://{html}") outer = page.locator("details.tool-param-collapsible-rows").first - button = outer.locator("summary .tool-param-rows-toggle").first + button = outer.locator( + ".tool-param-fold-controls .tool-param-rows-toggle" + ).first # Collapsed fold: the button is hidden. assert not button.is_visible() @@ -134,16 +139,23 @@ def test_closing_fold_restores_initial_state(self, page: Page) -> None: page.goto(f"file://{html}") outer = page.locator("details.tool-param-collapsible-rows").first - button = outer.locator("summary .tool-param-rows-toggle").first + button = outer.locator( + ".tool-param-fold-controls .tool-param-rows-toggle" + ).first outer.locator("summary").first.click() button.click() assert all(outer.evaluate(ROW_DETAILS_JS)) - # Close the outer fold via its summary, then reopen. - outer.locator("summary").first.click() + # Close the outer fold via its key-column toggle (the open summary + # is hidden for keyed rows — the ▼ in the key cell is the collapse + # control), then reopen the same way. + key_toggle = page.locator( + "tr.tool-param-row-fold > td.tool-param-key > .tool-param-key-toggle" + ).first + key_toggle.click() assert not outer.evaluate("el => el.open") - outer.locator("summary").first.click() + key_toggle.click() row_states = outer.evaluate(ROW_DETAILS_JS) assert not any(row_states), "reopened fold must show rows collapsed" @@ -158,7 +170,9 @@ def test_toggle_all_keeps_button_in_sync(self, page: Page) -> None: page.goto(f"file://{html}") outer = page.locator("details.tool-param-collapsible-rows").first - button = outer.locator("summary .tool-param-rows-toggle").first + button = outer.locator( + ".tool-param-fold-controls .tool-param-rows-toggle" + ).first page.locator("#toggleDetails").click() assert outer.evaluate("el => el.open") @@ -202,9 +216,13 @@ def test_expand_all_control_cascades(self, page: Page) -> None: ) # Selectively close one row: mixed state → top offers expand again. + # (Open keyed summaries are hidden — close via the key-column toggle.) outer = root.locator("details.tool-param-collapsible-rows").first - row = outer.locator(":scope > table > tbody > tr > td > details").first - row.locator("summary").first.click() + row_toggle = outer.locator( + ":scope > table > tbody > tr.tool-param-row-fold" + " > td.tool-param-key > .tool-param-key-toggle" + ).first + row_toggle.click() expect(expand_all).to_contain_text("expand all") # Re-expand, then collapse all back to the initial state. @@ -229,3 +247,45 @@ def test_global_toggle_all_activates_expand_all(self, page: Page) -> None: "el => Array.from(el.querySelectorAll('details')).every(d => d.open)" ) expect(expand_all).to_contain_text("collapse all") + + @pytest.mark.browser + def test_key_column_toggle_cycle_and_glyph_sync(self, page: Page) -> None: + """The whole-key button drives the row's fold; its state + (aria-expanded → CSS-rotated ▸ glyph) is derived from the actual + open state, so the expand-all path flips it too, not just direct + clicks.""" + html = self._render(_entries_with_structured_list()) + page.goto(f"file://{html}") + + outer = page.locator("details.tool-param-collapsible-rows").first + key_toggle = page.locator( + "tr.tool-param-row-fold > td.tool-param-key > .tool-param-key-toggle" + ).first + + # One constant ▸ glyph; open/closed is aria-expanded (CSS rotates). + assert "▸" in (key_toggle.text_content() or "") + expect(key_toggle).to_have_attribute("aria-expanded", "false") + key_toggle.click() + assert outer.evaluate("el => el.open") + # State updates arrive via the queued toggle event — poll. + expect(key_toggle).to_have_attribute("aria-expanded", "true") + key_toggle.click() + assert not outer.evaluate("el => el.open") + expect(key_toggle).to_have_attribute("aria-expanded", "false") + + # Externally-driven open (expand-all) must flip the state too. + page.locator(".tool-params-expand-all").first.click() + expect(key_toggle).to_have_attribute("aria-expanded", "true") + + @pytest.mark.browser + def test_no_interactive_elements_inside_summaries(self, page: Page) -> None: + """Accessibility contract (Chrome DevTools issue): no button/link/ + input may live inside any on the rendered page.""" + html = self._render(_entries_with_structured_list()) + page.goto(f"file://{html}") + offenders = page.evaluate( + "() => document.querySelectorAll(" + "'summary button, summary a, summary input, summary select'" + ").length" + ) + assert offenders == 0 diff --git a/test/test_params_table_hybrid.py b/test/test_params_table_hybrid.py index a0bfcb56..6733d056 100644 --- a/test/test_params_table_hybrid.py +++ b/test/test_params_table_hybrid.py @@ -88,8 +88,9 @@ def test_nested_dict_renders_table(self): def test_list_renders_indexed_rows(self): html = render_params_table({"items": ["alpha", "beta"]}) assert "tool-params-nested" in html - assert "
0101
- +
test_paramtest_param This tool will fail to demonstrate error handling
@@ -32549,9 +32603,9 @@
assistant_00 → assistant_00
-
+
- +
[ @@ -32560,15 +32614,15 @@ "id": "2", "content": "Implement core functionality", "status": "... -
+
- + - + - + - + - + " in html - assert "1" in html + assert "0" in html + assert "1" in html assert "alpha" in html and "beta" in html def test_structures_always_fold_regardless_of_size(self): @@ -177,7 +177,7 @@ def test_fold_rows_carry_key_column_toggle(self): # Scalar rows reserve the same glyph slot, empty. assert ( "" in html + "count" in html ) def test_empty_containers_fall_back_to_json_dump(self): From f452a8902aa8236c23f2c0656c2bce08be36538f Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Thu, 11 Jun 2026 21:32:15 +0200 Subject: [PATCH 08/12] Fold glyphs: use the browser's native disclosure markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rotated ▸ font glyph inherited the italic style of the control labels (a skewed triangle, then rotated — visibly off), and the ▶/▼ pair it had replaced has mismatched metrics in Chrome. The clean source of symmetric glyphs was on the page all along: the UA-drawn disclosure marker that elements show. Render it on the (now empty) glyph spans via display:list-item + list-style-type: disclosure-closed/open — same icon as the summaries, geometrically symmetric, immune to italics. String list-style-type values provide the fallback where the disclosure-* keywords are unsupported, and the buttons lay out as inline-flex since list-item is block-level. The scalar-row spacer span stays markerless. Co-Authored-By: Claude Fable 5 --- .../templates/components/message_styles.css | 38 ++- claude_code_log/html/tool_formatters.py | 11 +- test/__snapshots__/test_snapshot_html.ambr | 280 +++++++++++++----- test/test_params_rows_toggle_browser.py | 6 +- 4 files changed, 248 insertions(+), 87 deletions(-) diff --git a/claude_code_log/html/templates/components/message_styles.css b/claude_code_log/html/templates/components/message_styles.css index d5d84556..99664e26 100644 --- a/claude_code_log/html/templates/components/message_styles.css +++ b/claude_code_log/html/templates/components/message_styles.css @@ -1060,23 +1060,43 @@ pre > code { color: var(--text-primary); } -/* The shared fold glyph: one constant ▸, CSS-rotated 90° when its control - is expanded — symmetric open/closed by construction. Centered in a fixed - slot so the box-center rotation pivots on the glyph itself (a left- - aligned glyph in a wide box swings sideways when the box rotates). The - slot doubles as the key-column alignment spacer: every key cell renders - one (empty on scalar rows), so all key texts start at the same x. */ +/* The shared fold glyph: the BROWSER'S native disclosure marker (the same + UA-drawn icon a shows) via display:list-item — geometrically + symmetric open/closed, and immune to the italic style of the button + labels (font glyphs like ▶/▼ skew in italics; the marker icon doesn't). + The empty span doubles as the key-column alignment slot: every key cell + renders one (markerless on scalar rows), so all key texts start at the + same x. */ .tool-param-fold-glyph { display: inline-block; width: 1.3em; - text-align: center; color: var(--text-muted); - transition: transform 0.15s ease; + font-style: normal; +} + +/* Marker only inside the toggle buttons — the scalar-row spacer stays + empty. list-style-type string values are the fallback where the + disclosure-* keywords are unsupported. */ +button > .tool-param-fold-glyph { + display: list-item; + list-style-position: inside; + list-style-type: "\25B8"; + list-style-type: disclosure-closed; } .tool-param-key-toggle[aria-expanded='true'] > .tool-param-fold-glyph, [data-state='expanded'] > .tool-param-fold-glyph { - transform: rotate(90deg); + list-style-type: "\25BE"; + list-style-type: disclosure-open; +} + +/* display:list-item is block-level — lay the buttons out as inline-flex so + the marker slot and the label sit on one line. */ +.tool-param-key-toggle, +.tool-params-expand-all, +.tool-param-rows-toggle { + display: inline-flex; + align-items: baseline; } /* The key glyph is THE marker for these rows — drop the duplicate native diff --git a/claude_code_log/html/tool_formatters.py b/claude_code_log/html/tool_formatters.py index 0ab231b0..a82c65ff 100644 --- a/claude_code_log/html/tool_formatters.py +++ b/claude_code_log/html/tool_formatters.py @@ -1133,7 +1133,7 @@ def _table_fold_html(formatted_value: str, table_html: str, kind: str) -> str: return f"""
{preview} -
+
{table_html}
""" @@ -1160,7 +1160,7 @@ def _params_root_html(table_html: str) -> str: "
" "" "
" f"{table_html}" @@ -1196,12 +1196,13 @@ def _params_table_html(items: "Iterable[tuple[Any, Any]]", depth: int) -> str: if value_html.lstrip().startswith(" shows, immune to italic contexts. key_cell = ( "" ) row_attr = " class='tool-param-row-fold'" diff --git a/test/__snapshots__/test_snapshot_html.ambr b/test/__snapshots__/test_snapshot_html.ambr index 69f02ffc..982e6b2c 100644 --- a/test/__snapshots__/test_snapshot_html.ambr +++ b/test/__snapshots__/test_snapshot_html.ambr @@ -1419,23 +1419,43 @@ color: var(--text-primary); } - /* The shared fold glyph: one constant ▸, CSS-rotated 90° when its control - is expanded — symmetric open/closed by construction. Centered in a fixed - slot so the box-center rotation pivots on the glyph itself (a left- - aligned glyph in a wide box swings sideways when the box rotates). The - slot doubles as the key-column alignment spacer: every key cell renders - one (empty on scalar rows), so all key texts start at the same x. */ + /* The shared fold glyph: the BROWSER'S native disclosure marker (the same + UA-drawn icon a shows) via display:list-item — geometrically + symmetric open/closed, and immune to the italic style of the button + labels (font glyphs like ▶/▼ skew in italics; the marker icon doesn't). + The empty span doubles as the key-column alignment slot: every key cell + renders one (markerless on scalar rows), so all key texts start at the + same x. */ .tool-param-fold-glyph { display: inline-block; width: 1.3em; - text-align: center; color: var(--text-muted); - transition: transform 0.15s ease; + font-style: normal; + } + + /* Marker only inside the toggle buttons — the scalar-row spacer stays + empty. list-style-type string values are the fallback where the + disclosure-* keywords are unsupported. */ + button > .tool-param-fold-glyph { + display: list-item; + list-style-position: inside; + list-style-type: "\25B8"; + list-style-type: disclosure-closed; } .tool-param-key-toggle[aria-expanded='true'] > .tool-param-fold-glyph, [data-state='expanded'] > .tool-param-fold-glyph { - transform: rotate(90deg); + list-style-type: "\25BE"; + list-style-type: disclosure-open; + } + + /* display:list-item is block-level — lay the buttons out as inline-flex so + the marker slot and the label sit on one line. */ + .tool-param-key-toggle, + .tool-params-expand-all, + .tool-param-rows-toggle { + display: inline-flex; + align-items: baseline; } /* The key glyph is THE marker for these rows — drop the duplicate native @@ -7708,23 +7728,43 @@ color: var(--text-primary); } - /* The shared fold glyph: one constant ▸, CSS-rotated 90° when its control - is expanded — symmetric open/closed by construction. Centered in a fixed - slot so the box-center rotation pivots on the glyph itself (a left- - aligned glyph in a wide box swings sideways when the box rotates). The - slot doubles as the key-column alignment spacer: every key cell renders - one (empty on scalar rows), so all key texts start at the same x. */ + /* The shared fold glyph: the BROWSER'S native disclosure marker (the same + UA-drawn icon a shows) via display:list-item — geometrically + symmetric open/closed, and immune to the italic style of the button + labels (font glyphs like ▶/▼ skew in italics; the marker icon doesn't). + The empty span doubles as the key-column alignment slot: every key cell + renders one (markerless on scalar rows), so all key texts start at the + same x. */ .tool-param-fold-glyph { display: inline-block; width: 1.3em; - text-align: center; color: var(--text-muted); - transition: transform 0.15s ease; + font-style: normal; + } + + /* Marker only inside the toggle buttons — the scalar-row spacer stays + empty. list-style-type string values are the fallback where the + disclosure-* keywords are unsupported. */ + button > .tool-param-fold-glyph { + display: list-item; + list-style-position: inside; + list-style-type: "\25B8"; + list-style-type: disclosure-closed; } .tool-param-key-toggle[aria-expanded='true'] > .tool-param-fold-glyph, [data-state='expanded'] > .tool-param-fold-glyph { - transform: rotate(90deg); + list-style-type: "\25BE"; + list-style-type: disclosure-open; + } + + /* display:list-item is block-level — lay the buttons out as inline-flex so + the marker slot and the label sit on one line. */ + .tool-param-key-toggle, + .tool-params-expand-all, + .tool-param-rows-toggle { + display: inline-flex; + align-items: baseline; } /* The key glyph is THE marker for these rows — drop the duplicate native @@ -16084,23 +16124,43 @@ color: var(--text-primary); } - /* The shared fold glyph: one constant ▸, CSS-rotated 90° when its control - is expanded — symmetric open/closed by construction. Centered in a fixed - slot so the box-center rotation pivots on the glyph itself (a left- - aligned glyph in a wide box swings sideways when the box rotates). The - slot doubles as the key-column alignment spacer: every key cell renders - one (empty on scalar rows), so all key texts start at the same x. */ + /* The shared fold glyph: the BROWSER'S native disclosure marker (the same + UA-drawn icon a shows) via display:list-item — geometrically + symmetric open/closed, and immune to the italic style of the button + labels (font glyphs like ▶/▼ skew in italics; the marker icon doesn't). + The empty span doubles as the key-column alignment slot: every key cell + renders one (markerless on scalar rows), so all key texts start at the + same x. */ .tool-param-fold-glyph { display: inline-block; width: 1.3em; - text-align: center; color: var(--text-muted); - transition: transform 0.15s ease; + font-style: normal; + } + + /* Marker only inside the toggle buttons — the scalar-row spacer stays + empty. list-style-type string values are the fallback where the + disclosure-* keywords are unsupported. */ + button > .tool-param-fold-glyph { + display: list-item; + list-style-position: inside; + list-style-type: "\25B8"; + list-style-type: disclosure-closed; } .tool-param-key-toggle[aria-expanded='true'] > .tool-param-fold-glyph, [data-state='expanded'] > .tool-param-fold-glyph { - transform: rotate(90deg); + list-style-type: "\25BE"; + list-style-type: disclosure-open; + } + + /* display:list-item is block-level — lay the buttons out as inline-flex so + the marker slot and the label sit on one line. */ + .tool-param-key-toggle, + .tool-params-expand-all, + .tool-param-rows-toggle { + display: inline-flex; + align-items: baseline; } /* The key glyph is THE marker for these rows — drop the duplicate native @@ -22488,23 +22548,43 @@ color: var(--text-primary); } - /* The shared fold glyph: one constant ▸, CSS-rotated 90° when its control - is expanded — symmetric open/closed by construction. Centered in a fixed - slot so the box-center rotation pivots on the glyph itself (a left- - aligned glyph in a wide box swings sideways when the box rotates). The - slot doubles as the key-column alignment spacer: every key cell renders - one (empty on scalar rows), so all key texts start at the same x. */ + /* The shared fold glyph: the BROWSER'S native disclosure marker (the same + UA-drawn icon a shows) via display:list-item — geometrically + symmetric open/closed, and immune to the italic style of the button + labels (font glyphs like ▶/▼ skew in italics; the marker icon doesn't). + The empty span doubles as the key-column alignment slot: every key cell + renders one (markerless on scalar rows), so all key texts start at the + same x. */ .tool-param-fold-glyph { display: inline-block; width: 1.3em; - text-align: center; color: var(--text-muted); - transition: transform 0.15s ease; + font-style: normal; + } + + /* Marker only inside the toggle buttons — the scalar-row spacer stays + empty. list-style-type string values are the fallback where the + disclosure-* keywords are unsupported. */ + button > .tool-param-fold-glyph { + display: list-item; + list-style-position: inside; + list-style-type: "\25B8"; + list-style-type: disclosure-closed; } .tool-param-key-toggle[aria-expanded='true'] > .tool-param-fold-glyph, [data-state='expanded'] > .tool-param-fold-glyph { - transform: rotate(90deg); + list-style-type: "\25BE"; + list-style-type: disclosure-open; + } + + /* display:list-item is block-level — lay the buttons out as inline-flex so + the marker slot and the label sit on one line. */ + .tool-param-key-toggle, + .tool-params-expand-all, + .tool-param-rows-toggle { + display: inline-flex; + align-items: baseline; } /* The key glyph is THE marker for these rows — drop the duplicate native @@ -29159,23 +29239,43 @@ color: var(--text-primary); } - /* The shared fold glyph: one constant ▸, CSS-rotated 90° when its control - is expanded — symmetric open/closed by construction. Centered in a fixed - slot so the box-center rotation pivots on the glyph itself (a left- - aligned glyph in a wide box swings sideways when the box rotates). The - slot doubles as the key-column alignment spacer: every key cell renders - one (empty on scalar rows), so all key texts start at the same x. */ + /* The shared fold glyph: the BROWSER'S native disclosure marker (the same + UA-drawn icon a shows) via display:list-item — geometrically + symmetric open/closed, and immune to the italic style of the button + labels (font glyphs like ▶/▼ skew in italics; the marker icon doesn't). + The empty span doubles as the key-column alignment slot: every key cell + renders one (markerless on scalar rows), so all key texts start at the + same x. */ .tool-param-fold-glyph { display: inline-block; width: 1.3em; - text-align: center; color: var(--text-muted); - transition: transform 0.15s ease; + font-style: normal; + } + + /* Marker only inside the toggle buttons — the scalar-row spacer stays + empty. list-style-type string values are the fallback where the + disclosure-* keywords are unsupported. */ + button > .tool-param-fold-glyph { + display: list-item; + list-style-position: inside; + list-style-type: "\25B8"; + list-style-type: disclosure-closed; } .tool-param-key-toggle[aria-expanded='true'] > .tool-param-fold-glyph, [data-state='expanded'] > .tool-param-fold-glyph { - transform: rotate(90deg); + list-style-type: "\25BE"; + list-style-type: disclosure-open; + } + + /* display:list-item is block-level — lay the buttons out as inline-flex so + the marker slot and the label sit on one line. */ + .tool-param-key-toggle, + .tool-params-expand-all, + .tool-param-rows-toggle { + display: inline-flex; + align-items: baseline; } /* The key glyph is THE marker for these rows — drop the duplicate native @@ -32603,9 +32703,9 @@
assistant_00 → assistant_00
-
00 broken_todo
{ @@ -32578,22 +32632,22 @@ "priority":... - + - + - + - +
idid 2
contentcontent Implement core functionality
statusstatus in_progress
prioritypriority high
@@ -32602,7 +32656,7 @@
{ @@ -32612,22 +32666,22 @@ "priority": "medium"... - + - + - + - +
idid 3
contentcontent Add comprehensive tests
statusstatus pending
prioritypriority medium
@@ -32636,7 +32690,7 @@
{ @@ -32647,22 +32701,22 @@ } - + - + - + - +
idid 4
contentcontent Write user documentation
statusstatus pending
prioritypriority low
@@ -32671,7 +32725,7 @@
{ @@ -32682,22 +32736,22 @@ } - + - + - + - +
idid 5
contentcontent Perform code review
statusstatus pending
prioritypriority medium
@@ -33043,10 +33097,13 @@ const allOpen = rows.length > 0 && Array.from(rows).every(row => row.open); const kind = button.dataset.kind || 'rows'; + // data-state drives the CSS rotation of the constant ▸ glyph; + // only the label text changes. button.dataset.state = allOpen ? 'expanded' : 'collapsed'; - button.textContent = allOpen - ? '▼ collapse all ' + kind - : '▶ expand all ' + kind; + const label = button.querySelector('.tool-param-fold-label'); + if (label) { + label.textContent = (allOpen ? 'collapse all ' : 'expand all ') + kind; + } } // Top-level expand-all for a whole params/result renderer: @@ -33059,7 +33116,10 @@ const allOpen = folds.length > 0 && Array.from(folds).every(fold => fold.open); button.dataset.state = allOpen ? 'expanded' : 'collapsed'; - button.textContent = allOpen ? '▼ collapse all' : '▶ expand all'; + const label = button.querySelector('.tool-param-fold-label'); + if (label) { + label.textContent = allOpen ? 'collapse all' : 'expand all'; + } } document.addEventListener('click', function (event) { @@ -35733,16 +35793,22 @@ color: var(--text-primary); } - /* Fixed glyph slot in EVERY key cell (empty spacer on scalar rows), so - all key texts start at the same x regardless of foldability. */ - .tool-param-key-glyph { + /* The shared fold glyph: one constant ▸, CSS-rotated 90° when its control + is expanded — symmetric open/closed by construction. Centered in a fixed + slot so the box-center rotation pivots on the glyph itself (a left- + aligned glyph in a wide box swings sideways when the box rotates). The + slot doubles as the key-column alignment spacer: every key cell renders + one (empty on scalar rows), so all key texts start at the same x. */ + .tool-param-fold-glyph { display: inline-block; width: 1.3em; + text-align: center; color: var(--text-muted); transition: transform 0.15s ease; } - .tool-param-key-toggle[aria-expanded='true'] > .tool-param-key-glyph { + .tool-param-key-toggle[aria-expanded='true'] > .tool-param-fold-glyph, + [data-state='expanded'] > .tool-param-fold-glyph { transform: rotate(90deg); } @@ -39608,10 +39674,13 @@ const allOpen = rows.length > 0 && Array.from(rows).every(row => row.open); const kind = button.dataset.kind || 'rows'; + // data-state drives the CSS rotation of the constant ▸ glyph; + // only the label text changes. button.dataset.state = allOpen ? 'expanded' : 'collapsed'; - button.textContent = allOpen - ? '▼ collapse all ' + kind - : '▶ expand all ' + kind; + const label = button.querySelector('.tool-param-fold-label'); + if (label) { + label.textContent = (allOpen ? 'collapse all ' : 'expand all ') + kind; + } } // Top-level expand-all for a whole params/result renderer: @@ -39624,7 +39693,10 @@ const allOpen = folds.length > 0 && Array.from(folds).every(fold => fold.open); button.dataset.state = allOpen ? 'expanded' : 'collapsed'; - button.textContent = allOpen ? '▼ collapse all' : '▶ expand all'; + const label = button.querySelector('.tool-param-fold-label'); + if (label) { + label.textContent = allOpen ? 'collapse all' : 'expand all'; + } } document.addEventListener('click', function (event) { @@ -42298,16 +42370,22 @@ color: var(--text-primary); } - /* Fixed glyph slot in EVERY key cell (empty spacer on scalar rows), so - all key texts start at the same x regardless of foldability. */ - .tool-param-key-glyph { + /* The shared fold glyph: one constant ▸, CSS-rotated 90° when its control + is expanded — symmetric open/closed by construction. Centered in a fixed + slot so the box-center rotation pivots on the glyph itself (a left- + aligned glyph in a wide box swings sideways when the box rotates). The + slot doubles as the key-column alignment spacer: every key cell renders + one (empty on scalar rows), so all key texts start at the same x. */ + .tool-param-fold-glyph { display: inline-block; width: 1.3em; + text-align: center; color: var(--text-muted); transition: transform 0.15s ease; } - .tool-param-key-toggle[aria-expanded='true'] > .tool-param-key-glyph { + .tool-param-key-toggle[aria-expanded='true'] > .tool-param-fold-glyph, + [data-state='expanded'] > .tool-param-fold-glyph { transform: rotate(90deg); } @@ -46000,10 +46078,13 @@ const allOpen = rows.length > 0 && Array.from(rows).every(row => row.open); const kind = button.dataset.kind || 'rows'; + // data-state drives the CSS rotation of the constant ▸ glyph; + // only the label text changes. button.dataset.state = allOpen ? 'expanded' : 'collapsed'; - button.textContent = allOpen - ? '▼ collapse all ' + kind - : '▶ expand all ' + kind; + const label = button.querySelector('.tool-param-fold-label'); + if (label) { + label.textContent = (allOpen ? 'collapse all ' : 'expand all ') + kind; + } } // Top-level expand-all for a whole params/result renderer: @@ -46016,7 +46097,10 @@ const allOpen = folds.length > 0 && Array.from(folds).every(fold => fold.open); button.dataset.state = allOpen ? 'expanded' : 'collapsed'; - button.textContent = allOpen ? '▼ collapse all' : '▶ expand all'; + const label = button.querySelector('.tool-param-fold-label'); + if (label) { + label.textContent = allOpen ? 'collapse all' : 'expand all'; + } } document.addEventListener('click', function (event) { diff --git a/test/test_params_table_hybrid.py b/test/test_params_table_hybrid.py index 6733d056..2a6f5d00 100644 --- a/test/test_params_table_hybrid.py +++ b/test/test_params_table_hybrid.py @@ -89,8 +89,8 @@ def test_list_renders_indexed_rows(self): html = render_params_table({"items": ["alpha", "beta"]}) assert "tool-params-nested" in html # Scalar rows: glyph spacer + index in the key cell. - assert "0
" - "count
+
- +
[ @@ -32614,7 +32714,7 @@ "id": "2", "content": "Implement core functionality", "status": "... -
+
@@ -32622,7 +32722,7 @@ - + - + - + - +
0
{ @@ -32656,7 +32756,7 @@
{ @@ -32690,7 +32790,7 @@
{ @@ -32725,7 +32825,7 @@
{ @@ -35793,23 +35893,43 @@ color: var(--text-primary); } - /* The shared fold glyph: one constant ▸, CSS-rotated 90° when its control - is expanded — symmetric open/closed by construction. Centered in a fixed - slot so the box-center rotation pivots on the glyph itself (a left- - aligned glyph in a wide box swings sideways when the box rotates). The - slot doubles as the key-column alignment spacer: every key cell renders - one (empty on scalar rows), so all key texts start at the same x. */ + /* The shared fold glyph: the BROWSER'S native disclosure marker (the same + UA-drawn icon a shows) via display:list-item — geometrically + symmetric open/closed, and immune to the italic style of the button + labels (font glyphs like ▶/▼ skew in italics; the marker icon doesn't). + The empty span doubles as the key-column alignment slot: every key cell + renders one (markerless on scalar rows), so all key texts start at the + same x. */ .tool-param-fold-glyph { display: inline-block; width: 1.3em; - text-align: center; color: var(--text-muted); - transition: transform 0.15s ease; + font-style: normal; + } + + /* Marker only inside the toggle buttons — the scalar-row spacer stays + empty. list-style-type string values are the fallback where the + disclosure-* keywords are unsupported. */ + button > .tool-param-fold-glyph { + display: list-item; + list-style-position: inside; + list-style-type: "\25B8"; + list-style-type: disclosure-closed; } .tool-param-key-toggle[aria-expanded='true'] > .tool-param-fold-glyph, [data-state='expanded'] > .tool-param-fold-glyph { - transform: rotate(90deg); + list-style-type: "\25BE"; + list-style-type: disclosure-open; + } + + /* display:list-item is block-level — lay the buttons out as inline-flex so + the marker slot and the label sit on one line. */ + .tool-param-key-toggle, + .tool-params-expand-all, + .tool-param-rows-toggle { + display: inline-flex; + align-items: baseline; } /* The key glyph is THE marker for these rows — drop the duplicate native @@ -42370,23 +42490,43 @@ color: var(--text-primary); } - /* The shared fold glyph: one constant ▸, CSS-rotated 90° when its control - is expanded — symmetric open/closed by construction. Centered in a fixed - slot so the box-center rotation pivots on the glyph itself (a left- - aligned glyph in a wide box swings sideways when the box rotates). The - slot doubles as the key-column alignment spacer: every key cell renders - one (empty on scalar rows), so all key texts start at the same x. */ + /* The shared fold glyph: the BROWSER'S native disclosure marker (the same + UA-drawn icon a shows) via display:list-item — geometrically + symmetric open/closed, and immune to the italic style of the button + labels (font glyphs like ▶/▼ skew in italics; the marker icon doesn't). + The empty span doubles as the key-column alignment slot: every key cell + renders one (markerless on scalar rows), so all key texts start at the + same x. */ .tool-param-fold-glyph { display: inline-block; width: 1.3em; - text-align: center; color: var(--text-muted); - transition: transform 0.15s ease; + font-style: normal; + } + + /* Marker only inside the toggle buttons — the scalar-row spacer stays + empty. list-style-type string values are the fallback where the + disclosure-* keywords are unsupported. */ + button > .tool-param-fold-glyph { + display: list-item; + list-style-position: inside; + list-style-type: "\25B8"; + list-style-type: disclosure-closed; } .tool-param-key-toggle[aria-expanded='true'] > .tool-param-fold-glyph, [data-state='expanded'] > .tool-param-fold-glyph { - transform: rotate(90deg); + list-style-type: "\25BE"; + list-style-type: disclosure-open; + } + + /* display:list-item is block-level — lay the buttons out as inline-flex so + the marker slot and the label sit on one line. */ + .tool-param-key-toggle, + .tool-params-expand-all, + .tool-param-rows-toggle { + display: inline-flex; + align-items: baseline; } /* The key glyph is THE marker for these rows — drop the duplicate native diff --git a/test/test_params_rows_toggle_browser.py b/test/test_params_rows_toggle_browser.py index a6aa0f28..47beb2bd 100644 --- a/test/test_params_rows_toggle_browser.py +++ b/test/test_params_rows_toggle_browser.py @@ -251,7 +251,7 @@ def test_global_toggle_all_activates_expand_all(self, page: Page) -> None: @pytest.mark.browser def test_key_column_toggle_cycle_and_glyph_sync(self, page: Page) -> None: """The whole-key button drives the row's fold; its state - (aria-expanded → CSS-rotated ▸ glyph) is derived from the actual + (aria-expanded → native disclosure marker) is derived from the actual open state, so the expand-all path flips it too, not just direct clicks.""" html = self._render(_entries_with_structured_list()) @@ -262,8 +262,8 @@ def test_key_column_toggle_cycle_and_glyph_sync(self, page: Page) -> None: "tr.tool-param-row-fold > td.tool-param-key > .tool-param-key-toggle" ).first - # One constant ▸ glyph; open/closed is aria-expanded (CSS rotates). - assert "▸" in (key_toggle.text_content() or "") + # The glyph is the native disclosure marker (a ::marker pseudo- + # element, not text); open/closed state is carried by aria-expanded. expect(key_toggle).to_have_attribute("aria-expanded", "false") key_toggle.click() assert outer.evaluate("el => el.open") From fe1e776c6465a4a9d8104dfaee16feead927a64d Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Thu, 11 Jun 2026 21:34:55 +0200 Subject: [PATCH 09/12] dev-docs: document the side-channel user-prompt rendering (#174 follow-up) Co-Authored-By: Claude Fable 5 --- dev-docs/workflows.md | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/dev-docs/workflows.md b/dev-docs/workflows.md index d7ca7501..3b687cef 100644 --- a/dev-docs/workflows.md +++ b/dev-docs/workflows.md @@ -152,10 +152,27 @@ on non-workflow rendering would be high). Key mechanics: - **Side-channel grafting** (`_graft_agent_sidechannel`): each agent's `entries` are re-rendered through a nested `generate_template_messages` call, then every produced node is - re-registered into the main ctx (fresh monotonic indices) and its - pairing references (`pair_first`/`pair_middle`/`pair_last`) remapped - into the new index space. The side-channel renders at FULL detail - regardless of the main render's level (see § 7). + re-registered into the main ctx (fresh monotonic indices), tagged + `in_workflow_sidechannel`, and its pairing references + (`pair_first`/`pair_middle`/`pair_last`) remapped into the new index + space. The side-channel renders at FULL detail regardless of the main + render's level (see § 7). +- **Side-channel user prompts** (`format_workflow_sidechannel_user_content` + in `html/user_formatters.py`, gated on the graft tag): these prompts + are large prose+JSON hybrids, so they render as escaping collapsible + Markdown with embedded JSON blocks **extracted** first + (`extract_embedded_json`): a lone `{`/`[` on its own line, through a + lone matching closer followed by a blank line (or EOF), accepted only + when `json.loads` parses it. Each block is substituted with a + z-prefixed UUID placeholder (every uuid group gets a `z` so the + SHA→commit-URL linkifier can't match inside it), the remainder renders + as Markdown, and the placeholders are swapped for the generic + params-table rendering of the parsed value (so hybrid-renderer + upgrades apply automatically). Blocks wider than + `_EMBEDDED_JSON_MAX_ITEMS` fall back to an escaped `
` fold —
+  generation-side breadth discipline. A placeholder landing in the
+  fold's preview becomes a compact `{…}` hint; the table renders once,
+  in the body.
 - **Counts**: `has_children`/`is_paired` are derived properties, and
   the stock `_mark_messages_with_children` ran pre-splice, so a
   bottom-up helper (`_recount_spliced_children`) computes the synthetic

From 02016a3866d695917e2aca5397ee64065cb88e89 Mon Sep 17 00:00:00 2001
From: Christian Boos 
Date: Thu, 11 Jun 2026 21:43:55 +0200
Subject: [PATCH 10/12] Skip embedded-JSON extraction inside fenced code blocks
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Reviewer-probed false-accept: a fenced example whose commentary follows a
blank line INSIDE the fence let the scan terminate at the lone closer,
json.loads accepted, and the params-table markup was substituted into the
rendered 
. The line scan now tracks ``` fence parity and skips
extraction while inside a fence. (A fence with no internal blank line was
already rejected by the closer check — pinned alongside the repro and a
parity-reset case.)

Co-Authored-By: Claude Fable 5 
---
 claude_code_log/html/user_formatters.py | 13 ++++++++++++-
 test/test_workflow_sidechannel_user.py  | 21 +++++++++++++++++++++
 2 files changed, 33 insertions(+), 1 deletion(-)

diff --git a/claude_code_log/html/user_formatters.py b/claude_code_log/html/user_formatters.py
index 5a67a277..b07ad122 100644
--- a/claude_code_log/html/user_formatters.py
+++ b/claude_code_log/html/user_formatters.py
@@ -306,9 +306,20 @@ def extract_embedded_json(text: str) -> "tuple[str, dict[str, Any]]":
     blocks: dict[str, Any] = {}
     closer_for = {"{": "}", "[": "]"}
     i = 0
+    in_fence = False
     while i < len(lines):
         stripped = lines[i].strip()
-        if stripped in closer_for:
+        # Track ``` fence parity: a JSON example inside a fenced code block
+        # must stay verbatim — extracting it would substitute the table
+        # markup INSIDE the rendered 
. (A fence with no internal
+        # blank line is already rejected by the closer check; this covers
+        # fences that carry commentary after a blank line.)
+        if stripped.startswith("```"):
+            in_fence = not in_fence
+            out_lines.append(lines[i])
+            i += 1
+            continue
+        if not in_fence and stripped in closer_for:
             # Scan to the next blank line (or EOF) — the candidate block end.
             j = i + 1
             while j < len(lines) and lines[j].strip():
diff --git a/test/test_workflow_sidechannel_user.py b/test/test_workflow_sidechannel_user.py
index e75a04a5..3904abdf 100644
--- a/test/test_workflow_sidechannel_user.py
+++ b/test/test_workflow_sidechannel_user.py
@@ -90,6 +90,27 @@ def test_prose_brace_paragraph_left_untouched(self) -> None:
         assert blocks == {}
         assert "this is prose" in substituted
 
+    def test_fenced_json_with_internal_blank_line_left_untouched(self) -> None:
+        # Reviewer repro: a fenced example whose commentary follows a blank
+        # line INSIDE the fence — without fence tracking, the scan terminates
+        # at the lone closer, json.loads accepts, and the table markup would
+        # be substituted inside the rendered 
.
+        text = (
+            "Spec:\n\n```\n{\n"
+            '  "a": 1\n'
+            "}\n\nplus commentary in the same fence\n```\n\nend"
+        )
+        substituted, blocks = extract_embedded_json(text)
+        assert blocks == {}
+        assert substituted == text
+
+    def test_json_after_closed_fence_still_extracts(self) -> None:
+        # Fence parity resets: a block AFTER a properly closed fence is fair
+        # game again.
+        text = f"```\ncode example\n```\n\nReal data:\n\n{_OBJECT_BLOCK}\n\nend"
+        _substituted, blocks = extract_embedded_json(text)
+        assert len(blocks) == 1
+
     def test_placeholder_has_no_bare_hex_run(self) -> None:
         # Every uuid group is z-prefixed so the SHA→commit-URL linkifier
         # (\b[0-9a-f]{7,40}\b) can never match inside a placeholder.

From ecf9007de95bbc21edd8b89c107bf112bda4d5c5 Mon Sep 17 00:00:00 2001
From: Christian Boos 
Date: Thu, 11 Jun 2026 21:48:52 +0200
Subject: [PATCH 11/12] Also skip extraction inside tilde fences (pre-approved
 review follow-up)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

~~~ fences are CommonMark too and mistune renders them as code blocks —
same false-accept shape as the backtick case. One shared parity toggle for
both fence kinds is an approximation (CommonMark closes a fence only with
the same character), but for this skip-heuristic mixing kinds at worst
skips an extraction, never corrupts one.

Co-Authored-By: Claude Fable 5 
---
 claude_code_log/html/user_formatters.py | 16 ++++++++++------
 test/test_workflow_sidechannel_user.py  | 11 +++++++++++
 2 files changed, 21 insertions(+), 6 deletions(-)

diff --git a/claude_code_log/html/user_formatters.py b/claude_code_log/html/user_formatters.py
index b07ad122..9253fff0 100644
--- a/claude_code_log/html/user_formatters.py
+++ b/claude_code_log/html/user_formatters.py
@@ -309,12 +309,16 @@ def extract_embedded_json(text: str) -> "tuple[str, dict[str, Any]]":
     in_fence = False
     while i < len(lines):
         stripped = lines[i].strip()
-        # Track ``` fence parity: a JSON example inside a fenced code block
-        # must stay verbatim — extracting it would substitute the table
-        # markup INSIDE the rendered 
. (A fence with no internal
-        # blank line is already rejected by the closer check; this covers
-        # fences that carry commentary after a blank line.)
-        if stripped.startswith("```"):
+        # Track fence parity (backtick AND tilde fences — both CommonMark):
+        # a JSON example inside a fenced code block must stay verbatim —
+        # extracting it would substitute the table markup INSIDE the
+        # rendered 
. (A fence with no internal blank line is
+        # already rejected by the closer check; this covers fences that
+        # carry commentary after a blank line.) One shared toggle for both
+        # fence kinds is an approximation — CommonMark closes a fence only
+        # with the same character — but for this skip-heuristic's purposes
+        # mixing kinds at worst skips an extraction, never corrupts one.
+        if stripped.startswith(("```", "~~~")):
             in_fence = not in_fence
             out_lines.append(lines[i])
             i += 1
diff --git a/test/test_workflow_sidechannel_user.py b/test/test_workflow_sidechannel_user.py
index 3904abdf..64d0bd07 100644
--- a/test/test_workflow_sidechannel_user.py
+++ b/test/test_workflow_sidechannel_user.py
@@ -104,6 +104,17 @@ def test_fenced_json_with_internal_blank_line_left_untouched(self) -> None:
         assert blocks == {}
         assert substituted == text
 
+    def test_tilde_fenced_json_with_internal_blank_line_left_untouched(self) -> None:
+        # Tilde fences are CommonMark too — same false-accept shape.
+        text = (
+            "Spec:\n\n~~~\n{\n"
+            '  "a": 1\n'
+            "}\n\nplus commentary in the same fence\n~~~\n\nend"
+        )
+        substituted, blocks = extract_embedded_json(text)
+        assert blocks == {}
+        assert substituted == text
+
     def test_json_after_closed_fence_still_extracts(self) -> None:
         # Fence parity resets: a block AFTER a properly closed fence is fair
         # game again.

From 89cc46ae309d85c6950b0e1c9fbbeecc4fd3e130 Mon Sep 17 00:00:00 2001
From: Christian Boos 
Date: Thu, 11 Jun 2026 22:21:29 +0200
Subject: [PATCH 12/12] dev-docs: state the non-empty contract of the
 embedded-JSON extraction (CR #217)

Co-Authored-By: Claude Fable 5 
---
 dev-docs/workflows.md | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/dev-docs/workflows.md b/dev-docs/workflows.md
index 3b687cef..790cd5fd 100644
--- a/dev-docs/workflows.md
+++ b/dev-docs/workflows.md
@@ -163,7 +163,9 @@ on non-workflow rendering would be high). Key mechanics:
   Markdown with embedded JSON blocks **extracted** first
   (`extract_embedded_json`): a lone `{`/`[` on its own line, through a
   lone matching closer followed by a blank line (or EOF), accepted only
-  when `json.loads` parses it. Each block is substituted with a
+  when `json.loads` parses it to a **non-empty** dict/list — empty
+  `{}`/`[]` stay inline (nothing to tabulate), as do blocks inside
+  fenced code. Each block is substituted with a
   z-prefixed UUID placeholder (every uuid group gets a `z` so the
   SHA→commit-URL linkifier can't match inside it), the remainder renders
   as Markdown, and the placeholders are swapped for the generic