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/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/claude_code_log/html/templates/components/message_styles.css b/claude_code_log/html/templates/components/message_styles.css index 971b473d..99664e26 100644 --- a/claude_code_log/html/templates/components/message_styles.css +++ b/claude_code_log/html/templates/components/message_styles.css @@ -1014,40 +1014,108 @@ pre > code { 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); } +/* 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; + color: var(--text-muted); + 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 { + 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 + 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 { @@ -1464,14 +1532,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. */ @@ -1554,3 +1624,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/templates/transcript.html b/claude_code_log/html/templates/transcript.html index f8c38f16..1c8a38c2 100644 --- a/claude_code_log/html/templates/transcript.html +++ b/claude_code_log/html/templates/transcript.html @@ -418,16 +418,22 @@

🔍 Search & Filter

// 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 && 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: @@ -440,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) { @@ -454,12 +463,20 @@

🔍 Search & Filter

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 => { @@ -486,6 +503,20 @@

🔍 Search & Filter

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/claude_code_log/html/tool_formatters.py b/claude_code_log/html/tool_formatters.py index 3745a962..a82c65ff 100644 --- a/claude_code_log/html/tool_formatters.py +++ b/claude_code_log/html/tool_formatters.py @@ -1116,12 +1116,15 @@ def _structured_value_html(value: "dict[Any, Any] | list[Any]", depth: int) -> s def _table_fold_html(formatted_value: str, table_html: str, kind: str) -> str: """Wrap a rendered params table in a collapsed fold. - When the table has row-level folds, the summary carries an explicit - collapse hint (instead of the generic ::after one) followed by a - rows-toggle button that expands/collapses them all at once (wired - up in transcript.html). The "`` is an accessibility violation Chrome + flags: not consistently reachable by keyboard/AT). When the table has + row-level folds, the rows-toggle button lives in a controls strip + AFTER the summary, inside the details — natively hidden while closed, + visible when open (wired up in transcript.html). The " 100: @@ -1129,7 +1132,8 @@ def _table_fold_html(formatted_value: str, table_html: str, kind: str) -> str: if " - {preview}collapse + {preview} +
{table_html} """ @@ -1155,7 +1159,9 @@ def _params_root_html(table_html: str) -> str: "
" "
" "" + " data-state='collapsed'>" + "" + "expand all" "
" f"{table_html}" "
" @@ -1173,15 +1179,41 @@ 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(" shows, immune to italic contexts. + 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}" + row_attr = "" html_parts.append(f""" - - {escaped_key} + + {key_cell} {value_html} """) diff --git a/claude_code_log/html/user_formatters.py b/claude_code_log/html/user_formatters.py index e1b46cbc..9253fff0 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,156 @@ 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 + in_fence = False + while i < len(lines): + stripped = lines[i].strip() + # 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
+            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():
+                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
+
+
+# Top-level breadth cap for extracted blocks, mirroring the params-table
+# breadth discipline (CodeRabbit, PR #216): a folded table still GENERATES
+# one row per element, so a huge embedded array must not tabulate. The
+# fallback is an escaped 
 in a fold — proportional to the source text,
+# like the un-extracted prompt would have been (and deliberately not
+# Pygments-highlighted, which is itself generation-heavy at this size).
+_EMBEDDED_JSON_MAX_ITEMS = 200
+
+
+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))}
+    if len(params) > _EMBEDDED_JSON_MAX_ITEMS:
+        dumped = escape_html(json.dumps(parsed, indent=2, ensure_ascii=False))
+        return (
+            "
" + f"{len(params)} items (JSON)" + f"
{dumped}
" + ) + 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/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..790cd5fd 100644 --- a/dev-docs/workflows.md +++ b/dev-docs/workflows.md @@ -152,10 +152,29 @@ 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 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 + 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
@@ -203,12 +222,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/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..982e6b2c 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 */
@@ -1373,40 +1373,108 @@
       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);
   }
   
+  /* 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;
+      color: var(--text-muted);
+      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 {
+      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
+     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 {
@@ -1823,14 +1891,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. */
@@ -1913,6 +1983,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);
@@ -4941,16 +5023,22 @@
               // 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 &&
                       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:
@@ -4963,7 +5051,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) {
@@ -4977,12 +5068,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 => {
@@ -5009,6 +5108,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).
@@ -6248,7 +6361,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 */
@@ -7569,40 +7682,108 @@
       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);
   }
   
+  /* 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;
+      color: var(--text-muted);
+      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 {
+      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
+     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 {
@@ -8019,14 +8200,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. */
@@ -8109,6 +8292,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);
@@ -11036,16 +11231,22 @@
               // 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 &&
                       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:
@@ -11058,7 +11259,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) {
@@ -11072,12 +11276,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 => {
@@ -11104,6 +11316,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).
@@ -12343,7 +12569,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 */
@@ -14531,7 +14757,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 */
@@ -15852,40 +16078,108 @@
       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);
   }
   
+  /* 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;
+      color: var(--text-muted);
+      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 {
+      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
+     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 {
@@ -16302,14 +16596,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. */
@@ -16392,6 +16688,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);
@@ -19535,16 +19843,22 @@
               // 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 &&
                       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:
@@ -19557,7 +19871,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) {
@@ -19571,12 +19888,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 => {
@@ -19603,6 +19928,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).
@@ -20842,7 +21181,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 */
@@ -22163,40 +22502,108 @@
       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);
   }
   
+  /* 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;
+      color: var(--text-muted);
+      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 {
+      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
+     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 {
@@ -22613,14 +23020,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. */
@@ -22703,6 +23112,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);
@@ -26113,16 +26534,22 @@
               // 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 &&
                       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:
@@ -26135,7 +26562,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) {
@@ -26149,12 +26579,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 => {
@@ -26181,6 +26619,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).
@@ -27420,7 +27872,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 */
@@ -28741,40 +29193,108 @@
       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);
   }
   
+  /* 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;
+      color: var(--text-muted);
+      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 {
+      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
+     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 {
@@ -29191,14 +29711,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. */
@@ -29281,6 +29803,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);
@@ -31923,7 +32457,7 @@
           
edge_004
- +
test_paramtest_param This tool will fail to demonstrate error handling
@@ -32169,9 +32703,9 @@
assistant_00 → assistant_00
-
- - +
todos
+ +
[ @@ -32179,15 +32713,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') diff --git a/test/test_workflow_browser.py b/test/test_workflow_browser.py index 1466d181..08c5e8eb 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(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). + 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 new file mode 100644 index 00000000..64d0bd07 --- /dev/null +++ b/test/test_workflow_sidechannel_user.py @@ -0,0 +1,228 @@ +"""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_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_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.
+        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.
+        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_breadth_cap_boundary(self) -> None:
+        # Generation-side discipline (CodeRabbit, PR #216): at the cap the
+        # block tabulates; past it, an escaped JSON fold — no one-
-per- + # element generation for huge embedded arrays. + import json as _json + + def prompt(n: int) -> str: + block = _json.dumps(list(range(n)), indent=2) + return f"Data:\n\n{block}\n\nEnd." + + at_cap = format_workflow_sidechannel_user_text(prompt(200)) + assert "tool-params-table" in at_cap + + past_cap = format_workflow_sidechannel_user_text(prompt(201)) + assert "tool-params-table" not in past_cap + assert "201 items (JSON)" in past_cap + assert "embedded-json" in past_cap + + def test_user_content_stays_escaped(self) -> None: + html = format_workflow_sidechannel_user_text( + 'Try :\n\n{\n "x": ""\n}\n\nEnd.' + ) + assert "
00 broken_todo
1
{ @@ -32197,22 +32732,22 @@ "priority":... - + - + - + - +
idid 2
contentcontent Implement core functionality
statusstatus in_progress
prioritypriority high
@@ -32220,8 +32755,8 @@
2
{ @@ -32231,22 +32766,22 @@ "priority": "medium"... - + - + - + - +
idid 3
contentcontent Add comprehensive tests
statusstatus pending
prioritypriority medium
@@ -32254,8 +32789,8 @@
3
{ @@ -32266,22 +32801,22 @@ } - + - + - + - +
idid 4
contentcontent Write user documentation
statusstatus pending
prioritypriority low
@@ -32289,8 +32824,8 @@
4
{ @@ -32301,22 +32836,22 @@ } - + - + - + - +
idid 5
contentcontent Perform code review
statusstatus pending
prioritypriority medium
@@ -32653,16 +33188,22 @@ // 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 && 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: @@ -32675,7 +33216,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) { @@ -32689,12 +33233,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 => { @@ -32721,6 +33273,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). @@ -33960,7 +34526,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 */ @@ -35281,40 +35847,108 @@ 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); } + /* 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; + color: var(--text-muted); + 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 { + 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 + 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 { @@ -35731,14 +36365,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. */ @@ -35821,6 +36457,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); @@ -39137,16 +39785,22 @@ // 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 && 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: @@ -39159,7 +39813,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) { @@ -39173,12 +39830,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 => { @@ -39205,6 +39870,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). @@ -40444,7 +41123,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 */ @@ -41765,40 +42444,108 @@ 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); } + /* 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; + color: var(--text-muted); + 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 { + 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 + 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 { @@ -42215,14 +42962,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. */ @@ -42305,6 +43054,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); @@ -45448,16 +46209,22 @@ // 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 && 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: @@ -45470,7 +46237,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) { @@ -45484,12 +46254,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 => { @@ -45516,6 +46294,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_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_params_rows_toggle_browser.py b/test/test_params_rows_toggle_browser.py index a9acdfe3..47beb2bd 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 → 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()) + 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 + + # 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") + # 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..2a6f5d00 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