diff --git a/CHANGELOG.md b/CHANGELOG.md index 74188cb..80c9e8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,38 @@ follow [SemVer](https://semver.org/). ## [Unreleased] +### Fixed +- **Multi-line item `detail` kept its shape on both rendering surfaces** + (round table `q-forms-hunk-review-001`, 2026-08-28). A `detail` + carrying more than one line — a diff hunk, a log excerpt — was + corrupted on the way out: + - **Widget**: it rendered as an inline `` inside a flex row with + no `white-space` rule, so HTML folded its newlines and leading + indentation into single spaces and a diff arrived as one run-on + line. A multi-line detail now renders as an `ae-detail-block` — + full-width, `white-space:pre-wrap`, monospace. Fixed across + `triage`, `assumption_review`, `progress` and `confirm` through one + shared `widget._detail_html` helper and one `CSS_BASE` rule. A + single-line detail still renders as the same inline span it always + did (output byte-identical). + - **Markdown**: it was interpolated into the item's bullet line, so + every line the detail started with `-` — every removed line of a + diff — parsed as a NEW bullet, and a `suggested` suffix landed on + the detail's last line. A multi-line detail now renders as an + indented code block under the bullet, with the suffix left on the + bullet. The block is INDENTED rather than fenced because + `_defuse_fences` breaks every three-backtick run in author text to + keep the reply skeleton's boundaries; for the same reason an + author's own ` ```lang ` wrapper is stripped rather than rendered + as defused noise. Fixed for `triage`, `assumption_review` and + `confirm` consequences. + +### Changed +- `FORM_THEME_CSS` budget raised 10 KB → 12 KB (chair-ruled + 2026-08-28) for the shared `ae-detail-block` rule in `CSS_BASE`; a + trim to fit under 10 KB was offered and declined. Current size + 10,263 B. The cap remains a design decision, not a ratchet. + ## [0.8.0] — 2026-08-24 Per-stage form telemetry: the lifecycle is now measurable end to end diff --git a/docs/reports/roundtable/q-forms-hunk-review-001.md b/docs/reports/roundtable/q-forms-hunk-review-001.md new file mode 100644 index 0000000..d561863 --- /dev/null +++ b/docs/reports/roundtable/q-forms-hunk-review-001.md @@ -0,0 +1,93 @@ +# Round table — a ninth construct? (q-forms-hunk-review-001) + +Curated stub — chair-promoted sections only. Full transcript: +`~/.attune/reports/roundtable/q-forms-hunk-review-001.md` +(machine-local, TTL-exempt). Board thread: `q-forms-hunk-review-001` +(2026-08-28, 1 round, 3/3 seats: Claude, Antigravity, Codex). + +**Question.** Should attune-forms build a ninth construct +`hunk_review`, or is a per-hunk diff review just `triage` / +`assumption_review` with code-shaped items plus a presentation flag? +Measured cost of a construct: ~19 files, +865 (ranking, PR #24) to ++1219 (assumption_review, PR #25). + +## Ruling: build nothing (3/3, board msgs 3/4/5) + +No seat argued for building the construct. The candidate fails +admission test #4 (no committed consumer) and no seat could state a +construct-defining validator rule under test #2 that `triage` does not +already cover. + +Two corrections the independent round made to the chair's held lean +(msg 2, deliberately not shown to the seats): + +1. **The reuse target is `triage`, not `assumption_review`.** All + three seats rejected `assumption_review` independently. Its + vocabulary is fixed *because* the vocabulary is the construct + (D2-a), so `accept` / `edit` / `reject` over hunks is semantically + wrong even though the answer shape matches. `triage`'s dispositions + are author-named; that is the correct member. +2. **The interim costs zero files, not three.** A verdict-only hunk + board is expressible in shipped `triage` slots with no library + change at all. + +## The zero-change encoding (msg 3, chair-promoted) + +| hunk review needs | existing `triage` slot | +|---|---| +| stable hunk key | `triage_items[].id` = `path@base_sha:start-end` | +| what changed | `triage_items[].detail` = the unified diff, fenced ` ```diff ` | +| file / severity | `triage_items[].tag` | +| verdict vocabulary | `dispositions` = `["apply","revise","drop"]` (author-named) | +| agent's proposal | `suggested` (already rendered as a proposal) | +| answer | `{hunk id: verdict}` — already the shape | + +Validated, on all four surfaces, for the price of a helper function in +the *consumer*. What is lost versus a purpose-built construct is +red/green diff rendering — a rendering gap, and rendering gaps do not +buy `QuestionType` members here (`list_style`, `progress_style` are the +standing precedent). + +## Needs, classified (msgs 3/4/5, unanimous) + +| Need | Kind | Verdict | +|---|---|---| +| file/line anchors | rendering + key stability | `id` already carries the key | +| `+`/`−` line semantics | rendering | flag | +| syntax highlighting | rendering (evaporates on 3 of 4 surfaces) | flag | +| hunk ordering | presentation; `ranking` is a different speech act | not a need | +| per-hunk replacement **code** | **answer-shape** | one field on `triage`, not a construct | +| partial-hunk / line-subset selection | **answer-shape**, genuinely new | **out of scope** — see below | + +**Line-level selection is the boundary.** It turns the answer from +`{key: scalar}` into `{key: subset-of-a-variable-list}`, and there is +no honest `AskUserQuestion` control for "lines 3, 4, 7 of 12" short of +an option-per-line explosion — it fails admission test #3 on the +flattest surface. `git add -p` line staging belongs to a widget-only +staging UI, not to a four-surface grammar. + +## Flip conditions (pre-recorded so the gate is a decision, not a drift) + +The backlog item stays gated. It flips to **build** only when all three +hold: + +1. A committed consumer is **already shipping** the `triage` encoding + above and can point at a wall. +2. The wall is **answer-shape, not paint**. If the wall is "I need to + hand back replacement code," the answer is an opt-in + `edit_dispositions: ["revise"]` on `triage` — lifting the + `assumption_review` edit lane (`{"edit": text}` + paired + `"..text"` + the text-iff-edit fold, all already written + and paid for). That is one field, **not** a construct. +3. Someone can state, in one sentence, the validator rule that *is* + `hunk_review`. The table named exactly one candidate — anchor + integrity — and the chair did **not** promote it; it is recorded in + the machine-local transcript (msg 3) as the seat's own stated risk + of waiting, not as project doctrine. + +## Open chair fact + +All three seats converged on the same follow-up (msgs 6/7/8): does the +prospective consumer need the human to hand back **code**, or only a +**verdict**? Verdict-only is free today; code-back is one field. The +answer decides which of the flip conditions is even reachable. diff --git a/src/attune_forms/markdown_surface.py b/src/attune_forms/markdown_surface.py index aa7b86e..f8bf5be 100644 --- a/src/attune_forms/markdown_surface.py +++ b/src/attune_forms/markdown_surface.py @@ -136,18 +136,62 @@ def _progress_lines(q: FormQuestion) -> list[str]: return lines +#: Indent that makes a block inside a ``- `` bullet an INDENTED code +#: block: 2 columns of list continuation + the 4 CommonMark requires. +_DETAIL_INDENT = " " * 6 + + +def _detail_block(detail: str) -> list[str]: + """A MULTI-LINE item ``detail`` as an indented code block under its bullet. + + Interpolating a multi-line detail into the bullet itself breaks out + of the list item: every line the detail starts with ``-`` — every + removed line of a diff — parses as a NEW bullet, and a trailing + ``suggested`` suffix lands on the detail's last line. + + The block is INDENTED rather than fenced because a fence cannot + survive this surface: :func:`_defuse_fences` breaks every run of + three backticks in author text so the trailing reply skeleton keeps + its boundaries. An indented block needs no backticks at all, so it + passes through untouched and its content stays literal. + + For the same reason an author's own `````lang`` wrapper is + STRIPPED rather than kept: defused, it would render as visible + backtick-plus-zero-width noise around the block. + """ + lines = detail.strip("\n").splitlines() + if lines and lines[0].lstrip().startswith("```"): + lines = lines[1:] + if lines and lines[-1].strip().startswith("```"): + lines = lines[:-1] + return [f"{_DETAIL_INDENT}{line}".rstrip() for line in lines] + + +def _item_row(head: str, detail: str | None, suffix: str = "") -> list[str]: + """One bullet row plus, for a multi-line ``detail``, its indented block. + + A single-line detail stays inline after an em dash (unchanged + rendering); a multi-line one moves below the bullet so ``suffix`` + (e.g. "→ suggested: `apply`") stays on the bullet instead of being + glued onto the detail's closing fence. + """ + multiline = bool(detail) and "\n" in (detail or "") + inline = f" — {detail}" if detail and not multiline else "" + rows = [f"{head}{inline}{suffix}"] + if multiline: + rows.extend(_detail_block(detail or "")) + return rows + + def _triage_lines(q: FormQuestion) -> list[str]: """Item rows + the shared ruling vocabulary for a TRIAGE question.""" vocabulary = " / ".join(f"`{d}`" for d in q.dispositions or []) lines = [f"Rule each item as one of: {vocabulary}"] for key, item in expansion_items(q): tag = f" `{item['tag']}`" if item.get("tag") else "" - detail = f" — {item['detail']}" if item.get("detail") else "" - line = f"- **{item.get('label', '')}**{tag}{detail}" pick = suggested_pick(q, key) - if pick is not None: - line += f" → suggested: `{pick}`" - lines.append(line) + suffix = f" → suggested: `{pick}`" if pick is not None else "" + lines.extend(_item_row(f"- **{item.get('label', '')}**{tag}", item.get("detail"), suffix)) return lines @@ -167,13 +211,10 @@ def _assumption_lines(q: FormQuestion) -> list[str]: accept, edit, reject = ASSUMPTION_RULINGS lines = [f"Rule each assumption: `{accept}` / `{edit}: ` / `{reject}`"] for key, item in expansion_items(q): - detail = f" — {item['detail']}" if item.get("detail") else "" source = f" *(from {item['source']})*" if item.get("source") else "" - line = f"- **{item.get('label', '')}**{detail}{source}" pick = suggested_pick(q, key) - if pick is not None: - line += f" → suggested: `{pick}`" - lines.append(line) + suffix = source + (f" → suggested: `{pick}`" if pick is not None else "") + lines.extend(_item_row(f"- **{item.get('label', '')}**", item.get("detail"), suffix)) return lines @@ -225,8 +266,7 @@ def _control_lines(q: FormQuestion) -> list[str]: lines = ["If approved:"] for item in confirm_consequences(q): tag = f" `{item['severity']}`" if item.get("severity") else "" - detail = f" — {item['detail']}" if item.get("detail") else "" - lines.append(f"- {item.get('label', '')}{tag}{detail}") + lines.extend(_item_row(f"- {item.get('label', '')}{tag}", item.get("detail"))) lines.append("") lines.append("Answer one of: " + " / ".join(f"**{opt}**" for opt in q.options)) return lines diff --git a/src/attune_forms/theme.py b/src/attune_forms/theme.py index fb97516..ade384d 100644 --- a/src/attune_forms/theme.py +++ b/src/attune_forms/theme.py @@ -15,15 +15,20 @@ stylesheet renders native on claude.ai (host tokens win, light/dark follows the host) and standalone (fallbacks win). -Budget: ``FORM_THEME_CSS`` is capped at 10 KB raw by +Budget: ``FORM_THEME_CSS`` is capped at 12 KB raw by ``test_form_theme_budget`` (4 KB → 6 KB by chair ruling 2026-07-31; 6 KB → 8 KB ratified with the grammar-expansion merge 2026-08-14 — TRIAGE + CONFIRM families + deliberation seat chips, 8,158 B; 8 KB → 10 KB ratified 2026-08-15 for the 0.6.0 constructs, ranking-construct spec D2-a — the RANK family did not fit under 8 KB; a CSS consolidation pass was offered and not chosen, so the cap is NOT a -ratchet: growth past it is a design decision, not a drift) — no fonts, -no icon fonts, no images, no @import. +ratchet: growth past it is a design decision, not a drift; 10 KB → +12 KB chair-ruled 2026-08-28 for the shared ``ae-detail-block`` rule +in CSS_BASE, which lets a MULTI-LINE item detail — a diff hunk, a log +excerpt — keep its newlines instead of collapsing inside a flex row, +across triage / assumption_review / progress / confirm at once; a +trim to fit under 10 KB was offered and declined) — no fonts, no icon +fonts, no images, no @import. Copyright 2026 Smart-AI-Memory Licensed under Apache 2.0 @@ -66,6 +71,9 @@ color:var(--text-accent,#a1571c); } #attune-elicit-form .ae-field-missing { border-left:3px solid var(--text-accent,#a1571c); padding-left:.6rem; } +#attune-elicit-form .ae-detail-block { flex-basis:100%; white-space:pre-wrap; + font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; + overflow-x:auto; } """ #: INPUT — text_input, textarea, number, date, boolean, non-list single_select. @@ -123,7 +131,7 @@ CSS_PROGRESS = """#attune-elicit-form .ae-progress { display:flex; flex-direction:column; gap:.5rem; } #attune-elicit-form .ae-prog-rows { display:flex; flex-direction:column; gap:.25rem; } #attune-elicit-form .ae-prog-row { display:flex; align-items:baseline; gap:.5rem; - font-size:14px; color:var(--text-secondary,#5f5e59); } + flex-wrap:wrap; font-size:14px; color:var(--text-secondary,#5f5e59); } #attune-elicit-form .ae-prog-icon { flex:none; font-weight:700; width:1.1em; text-align:center; } #attune-elicit-form .ae-prog-done .ae-prog-icon { color:var(--text-success,#3fb950); } @@ -168,7 +176,7 @@ #attune-elicit-form .ae-gate-h { font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.03em; color:var(--text-accent,#a1571c); } #attune-elicit-form .ae-gate-row { display:flex; align-items:baseline; gap:.5rem; - font-size:14px; color:var(--text-secondary,#5f5e59); } + flex-wrap:wrap; font-size:14px; color:var(--text-secondary,#5f5e59); } #attune-elicit-form .ae-gate-label { color:var(--text-primary,#2c2c2a); } #attune-elicit-form .ae-gate-detail { font-size:13px; color:var(--text-muted,#8a887f); } #attune-elicit-form .ae-gate-tag { flex:none; font-size:10px; font-weight:600; diff --git a/src/attune_forms/widget.py b/src/attune_forms/widget.py index 47e0da9..0d0f74c 100644 --- a/src/attune_forms/widget.py +++ b/src/attune_forms/widget.py @@ -151,9 +151,7 @@ def _control_progress_report_html(q: FormQuestion) -> str: if it.get("label") in q.options: continue tag = f'{_esc(it.get("status", ""))}' - detail = ( - f'{_esc(it["detail"])}' if it.get("detail") else "" - ) + detail = _detail_html(it["detail"], "ae-prog-detail") if it.get("detail") else "" rows += ( f'
' f'{tag}{_esc(it.get("label", ""))}' @@ -207,11 +205,7 @@ def _control_progress_html(q: FormQuestion) -> str: ) for status_key, icon, sr in status_rows: for it in by_status[status_key]: - detail = ( - f'{_esc(it["detail"])}' - if it.get("detail") - else "" - ) + detail = _detail_html(it["detail"], "ae-prog-detail") if it.get("detail") else "" rows += ( f'
' f'' @@ -276,6 +270,25 @@ def _control_deliberation_html(q: FormQuestion) -> str: return f'
{cards}
' +def _detail_html(text: str, cls: str) -> str: + """An item ``detail`` as an inline span, or a BLOCK when multi-line. + + A multi-line detail — a diff hunk, a log excerpt — rendered as an + inline span inside a flex row collapses: HTML folds its newlines and + leading indentation into single spaces, so a diff arrives as one + run-on line (found probing the triage encoding for hunk review, + round table q-forms-hunk-review-001). As an ``ae-detail-block`` it + takes its own full-width line with ``white-space:pre-wrap`` and + keeps the shape the author wrote. + + Single-line details stay inline spans — the common case renders + exactly as before. + """ + if "\n" in text: + return f'
{_esc(text)}
' + return f'{_esc(text)}' + + def _control_triage_html(q: FormQuestion) -> str: """Render a TRIAGE control: one row per item, each row its own disposition radiogroup. @@ -290,11 +303,7 @@ def _control_triage_html(q: FormQuestion) -> str: for idx, (key, item) in enumerate(expansion_items(q)): label = item.get("label", "") tag = f'{_esc(item["tag"])}' if item.get("tag") else "" - detail = ( - f'{_esc(item["detail"])}' - if item.get("detail") - else "" - ) + detail = _detail_html(item["detail"], "ae-triage-detail") if item.get("detail") else "" pick = suggested_pick(q, key) opts = "" for disposition in q.dispositions or []: @@ -330,11 +339,7 @@ def _control_confirm_html(q: FormQuestion) -> str: if item.get("severity") else "" ) - detail = ( - f'{_esc(item["detail"])}' - if item.get("detail") - else "" - ) + detail = _detail_html(item["detail"], "ae-gate-detail") if item.get("detail") else "" rows += ( f'
{tag}' f'{_esc(item.get("label", ""))}' @@ -430,11 +435,7 @@ def _control_assumption_review_html(q: FormQuestion) -> str: if item.get("source") else "" ) - detail = ( - f'{_esc(item["detail"])}' - if item.get("detail") - else "" - ) + detail = _detail_html(item["detail"], "ae-triage-detail") if item.get("detail") else "" pick = suggested_pick(q, key) opts = "" for ruling in ASSUMPTION_RULINGS: diff --git a/tests/test_form_theme.py b/tests/test_form_theme.py index 04be648..18facba 100644 --- a/tests/test_form_theme.py +++ b/tests/test_form_theme.py @@ -13,7 +13,7 @@ #: merge, 8,158 B; 8 KB -> 10 KB ratified 2026-08-15, ranking-construct #: decisions.md D2-a — a consolidation pass was offered and NOT chosen, #: so the cap is not a ratchet: the next raise needs its own ruling). -_BUDGET_BYTES = 10240 +_BUDGET_BYTES = 12288 #: ``var(--name)`` with NO fallback value — the pattern the theme #: must never contain (host-token fallbacks are the design). diff --git a/tests/test_markdown_surface.py b/tests/test_markdown_surface.py index a977ab0..bb51d5e 100644 --- a/tests/test_markdown_surface.py +++ b/tests/test_markdown_surface.py @@ -195,3 +195,96 @@ def test_falsy_zero_default_survives_in_skeleton(self) -> None: } ) assert _skeleton(form_to_markdown(form))["answers"]["retries"] == 0 + + +# --- multi-line item detail (regression, 2026-08-28) ----------------- +# +# A multi-line ``detail`` — a diff hunk, a log excerpt — used to be +# interpolated straight into the bullet: the opening fence landed +# mid-line so it never opened a code block, every removed line of a +# diff parsed as a NEW bullet, and a "suggested" suffix was glued onto +# the detail's closing fence. Found by probing the zero-change triage +# encoding for hunk review (round table q-forms-hunk-review-001). + +_DIFF = "```diff\n@@ -88,7 +88,9 @@\n- while True:\n+ for _ in range(3):\n```" + + +def _hunk_board(**extra) -> dict: + item = {"id": "src/bridge.py@a1b2c3d:88-96", "label": "bound the retry loop", "detail": _DIFF} + return { + "title": "t", + "fields": [ + { + "id": "hunks", + "type": "triage", + "text": "Rule each hunk.", + "triage_items": [item], + "dispositions": ["apply", "revise", "drop"], + **extra, + } + ], + } + + +def test_multiline_detail_moves_below_the_bullet() -> None: + """The detail is an indented block, not part of the bullet line.""" + md = form_to_markdown(form_from_dict(_hunk_board())) + bullet = next(ln for ln in md.splitlines() if ln.startswith("- **bound the retry loop**")) + assert "```" not in bullet, "fence must not open mid-bullet" + # Indented, not fenced: _defuse_fences would break any fence we + # emitted, so the block relies on indentation alone. + assert " @@ -88,7 +88,9 @@" in md + + +def test_multiline_detail_removed_lines_stay_inside_the_fence() -> None: + """A diff's ``-`` lines must not parse as new bullets.""" + md = form_to_markdown(form_from_dict(_hunk_board())) + assert " - while True:" in md + assert not any( + ln.startswith("- while True:") for ln in md.splitlines() + ), "removed line escaped the fence and became a bullet" + + +def test_multiline_detail_keeps_suggested_on_the_bullet() -> None: + """``suggested`` rides the bullet, never the detail's closing fence.""" + board = _hunk_board(suggested={"src/bridge.py@a1b2c3d:88-96": "apply"}) + md = form_to_markdown(form_from_dict(board)) + bullet = next(ln for ln in md.splitlines() if ln.startswith("- **bound the retry loop**")) + assert bullet.endswith("→ suggested: `apply`") + + +def test_single_line_detail_still_renders_inline() -> None: + """The common case is unchanged — no gratuitous block.""" + board = _hunk_board() + board["fields"][0]["triage_items"][0]["detail"] = "worker.py:88" + md = form_to_markdown(form_from_dict(board)) + assert "- **bound the retry loop** — worker.py:88" in md + + +def test_unfenced_multiline_detail_is_still_indented() -> None: + """An author who did not fence the block still gets literal lines.""" + board = _hunk_board() + board["fields"][0]["triage_items"][0]["detail"] = "line one\n- line two\nline three" + md = form_to_markdown(form_from_dict(board)) + assert " line one" in md + assert " - line two" in md + + +def test_author_fence_is_stripped_not_defused() -> None: + """A ```lang wrapper cannot survive this surface — drop it. + + ``_defuse_fences`` breaks every three-backtick run so the reply + skeleton keeps its boundaries; a kept wrapper would therefore render + as visible backtick-plus-zero-width noise around the block. + """ + md = form_to_markdown(form_from_dict(_hunk_board())) + detail = [ln for ln in md.splitlines() if ln.startswith(" ")] + assert detail, "detail block missing" + assert not any("`" in ln for ln in detail), "author fence survived into the block" + + +def test_multiline_detail_keeps_the_skeleton_round_tripping() -> None: + """The block must not desync the trailing answers skeleton.""" + form = form_from_dict(_hunk_board()) + skeleton = _skeleton(form_to_markdown(form)) + assert skeleton["answers"] == {"hunks": {"src/bridge.py@a1b2c3d:88-96": None}} diff --git a/tests/test_widget.py b/tests/test_widget.py index f53a562..dd2ad15 100644 --- a/tests/test_widget.py +++ b/tests/test_widget.py @@ -272,3 +272,117 @@ def test_quote_in_id_does_not_break_attribute(self): html = _render([{"id": 'a"x', "text": "A?", "type": "text_input"}]) assert 'data-fid="a"x"' not in html assert """ in html + + +class TestMultilineItemDetail: + """A multi-line ``detail`` — a diff hunk, a log excerpt — must keep + its shape (regression, 2026-08-28). + + It used to render as a ```` INSIDE ``.ae-triage-head``, a flex + row with no ``white-space`` rule: newlines and leading indentation + collapsed to single spaces, so a diff arrived as one run-on line. + Found by probing the zero-change triage encoding for hunk review + (round table q-forms-hunk-review-001). + """ + + DIFF = "@@ -88,7 +88,9 @@\n- while True:\n+ for _ in range(3):" + + def _triage(self) -> str: + return _render( + [ + { + "id": "hunks", + "type": "triage", + "text": "Rule each hunk.", + "triage_items": [ + { + "id": "h1", + "label": "bound the retry loop", + "tag": "high", + "detail": self.DIFF, + } + ], + "dispositions": ["apply", "revise", "drop"], + } + ] + ) + + def _assumption(self) -> str: + return _render( + [ + { + "id": "assumed", + "type": "assumption_review", + "text": "Rule each.", + "assumptions": [ + {"id": "a1", "label": "3.10 is the floor", "detail": self.DIFF} + ], + } + ] + ) + + def test_triage_detail_becomes_a_block(self): + assert '
' in self._triage() + + def test_assumption_detail_becomes_a_block(self): + assert '
' in self._assumption() + + def test_progress_detail_becomes_a_block(self): + html = _render( + [ + { + "id": "p", + "type": "progress", + "text": "Where are we?", + # Blocked items become picker CARDS; the detail rows + # this fix touches come from done / in_flight. + "progress_items": [ + {"label": "migrate the schema", "status": "done", "detail": self.DIFF}, + {"label": "ship it", "status": "blocked"}, + ], + "options": ["ship it"], + } + ] + ) + assert '
' in html + + def test_confirm_detail_becomes_a_block(self): + html = _render( + [ + { + "id": "c", + "type": "confirm", + "text": "Go?", + "consequences": [ + {"label": "flag flips", "severity": "high", "detail": self.DIFF} + ], + } + ] + ) + assert '
' in html + + def test_single_line_detail_stays_an_inline_span(self): + """The common case is byte-identical to before the fix.""" + html = _render( + [ + { + "id": "hunks", + "type": "triage", + "text": "Rule each hunk.", + "triage_items": [{"id": "h1", "label": "L", "detail": "worker.py:88"}], + "dispositions": ["apply", "drop"], + } + ] + ) + assert 'worker.py:88' in html + assert 'ae-detail-block">' not in html, "no block markup for a single-line detail" + + def test_newlines_and_indentation_survive(self): + assert "\n- while True:" in self._triage() + + def test_css_preserves_whitespace(self): + html = self._triage() + rule = html[html.index(".ae-detail-block {") :] + rule = rule[: rule.index("}")] + assert "white-space:pre-wrap" in rule + assert "monospace" in rule