diff --git a/.claude/human-queue.md b/.claude/human-queue.md
index c206ad10..23bf42bf 100644
--- a/.claude/human-queue.md
+++ b/.claude/human-queue.md
@@ -1647,3 +1647,28 @@ RAN fast suite green (8427 passed) with all changes present; browser tests 75
SAW No queue entry in the original commit (discipline slip — repaired here).
WHY New CLI surfaces (--browser auto, signals mode) are AMBER per the envelope.
NEXT Issue #3 per-rule rubric tests + #2 live-probe e2e evidence: queued for the next wave.
+## 2026-08-17 slice 2 (wave 6, issue #30) — AMBER — new `untell humanize --html` surface (new CLI flag + HTML artifact)
+
+WHAT Issue #30: `untell humanize --html` now writes a self-contained, deterministic HTML
+ report of the run to stdout: before/after text, pre/post scores, seed, whether it
+ rewrote, the unified --diff payload, and a per-span lock annotation (via the
+ explain/lock machinery — same single source of truth as lock()). Every dynamic value
+ is HTML-escaped (fuzz-tested against ", " alert(1)",
+ "",
+ ' ',
+ '">',
+ "bold & italic ",
+ "quote ' single \" double",
+ "",
+]
+
+
+def test_all_hostile_shapes_are_inert_in_the_report() -> None:
+ for hostile in _HOSTILE:
+ html = render_humanize_html(
+ hostile,
+ hostile, # unchanged path still renders the hostile text
+ *_scores(0.5, 0.5), 1, "converged",
+ diff={"hunks": []}, seed=1, rewrote=False,
+ )
+ # The escaped spelling appears; a live tag never does. Escaping neutralises the
+ # tag boundaries ("><"), so the real invariant is that no literal `tagname` opens.
+ assert "<" in html or "'" in html or """ in html
+ for marker in ("', "rules": ["evil"],
+ "rationale": "note with markup"},
+ ],
+ "locks_preserved": 0,
+ }
+ html = render_humanize_html("in", "in", *_scores(0.5, 0.5), 1, "converged", diff=diff, seed=1)
+ assert "" not in html
+ assert "<script>bad()</script>" in html
+ assert "<b>with</b>" in html
+
+
+def test_hostile_warning_is_escaped() -> None:
+ html = render_humanize_html(
+ "a", "b", *_scores(0.8, 0.2), 1, "converged",
+ warning='carried payload', diff=_sample_diff("a", "b"), seed=1, rewrote=True,
+ )
+ assert "" not in html
+ assert "<script>warn()</script>" in html
+
+
+# ---------------------------------------------------------------------------
+# the renderer carries the run
+# ---------------------------------------------------------------------------
+
+
+def test_the_report_carries_scores_seed_rewrote_and_stopped() -> None:
+ pre, post = _scores(0.82, 0.19)
+ html = render_humanize_html(
+ AI, EDITED, pre, post, 3, "converged",
+ diff=_sample_diff(AI, EDITED), seed=42, rewrote=True, tells_before=9, tells_after=3,
+ )
+ assert "0.8200" in html and "0.1900" in html
+ assert "seed: 42" in html
+ assert "Rewrote" in html
+ assert "3 iterations" in html
+ assert "Converged" in html # the stopped reason, title-cased from the enum value
+ assert "AI tells" in html and "9" in html and "3" in html
+
+
+def test_the_report_says_when_it_did_not_rewrite() -> None:
+ html = render_humanize_html(AI, AI, *_scores(0.8, 0.8), 0, "no-op", seed=3, rewrote=False, diff={"hunks": []})
+ assert "Returned unchanged" in html or "no change" in html
+
+
+def test_the_lock_annotations_are_present_with_rationale() -> None:
+ html = render_humanize_html(AI, EDITED, *_scores(0.80, 0.20), 1, "converged", diff=_sample_diff(AI, EDITED), seed=7)
+ assert "Locked spans" in html
+ assert "Smith (2020)" in html and "47%" in html
+ # Rationale text from the explain registry rides along.
+ assert "citation" in html.lower() or "number" in html.lower()
+
+
+def test_the_diff_hunks_render() -> None:
+ html = render_humanize_html(AI, EDITED, *_scores(0.80, 0.20), 1, "converged", diff=_sample_diff(AI, EDITED), seed=7)
+ assert "Diff" in html
+ assert "@@" in html # a unified-diff hunk header
+ assert "deeply reshaped" in html
+
+
+# ---------------------------------------------------------------------------
+# CLI contract
+# ---------------------------------------------------------------------------
+
+
+class _EditRW:
+ name = "edit"
+
+ def available(self):
+ return True
+
+ def rewrite(self, text, score_result, threshold=0.30):
+ return text.replace("fundamentally transformed", "deeply reshaped")
+
+
+class _IdentityRW:
+ name = "identity"
+
+ def available(self):
+ return True
+
+ def rewrite(self, text, score_result, threshold=0.30):
+ return text
+
+
+def _fixed_score(mapping: dict, default: float = 0.8):
+ def _s(text, tier="full", threshold=0.3):
+ mx = mapping.get(text.strip(), default)
+ return {
+ "tier": tier,
+ "detectors": {"perplexity_burstiness": mx},
+ "max": mx,
+ "mean": mx,
+ "threshold": threshold,
+ "flagged": mx >= threshold,
+ "scored": True,
+ }
+
+ return _s
+
+
+def _patch_cli(monkeypatch, rewriter, scores: dict, default: float = 0.8) -> None:
+ import untell.rewriter as rewriter_mod
+ import untell.scripts.run as run_mod
+
+ monkeypatch.setenv("UNTELL_LITE_NO_TORCH", "1")
+ monkeypatch.setattr(rewriter_mod, "get_rewriter", lambda prefer=None: rewriter)
+ monkeypatch.setattr(run_mod, "score_text", _fixed_score(scores, default))
+
+
+def test_cli_escapes_injected_text(monkeypatch, capsys) -> None:
+ """A hostile document must come out of `--html` fully escaped — a defect if it
+ does not, so it is asserted at the CLI surface, not only in the renderer."""
+ _patch_cli(monkeypatch, _IdentityRW(), {}, default=0.8)
+ hostile = " AI cost $500 per Smith (2020)."
+ rc = main(["--tier", "lite", "--html", "--max-iters", "1", "--best-of", "1", hostile])
+ assert rc == 0
+ html = capsys.readouterr().out
+ assert "" not in html
+ assert "<script>alert(1)</script>" in html
+
+
+def test_cli_html_human_output(monkeypatch, capsys) -> None:
+ _patch_cli(monkeypatch, _EditRW(), {AI: 0.80, EDITED: 0.20})
+ rc = main(["--tier", "lite", "--html", "--max-iters", "1", "--best-of", "1", AI])
+ assert rc == 0
+ out = capsys.readouterr().out
+ assert out.startswith(""), "stdout must BE the html document, not a wrapper"
+ assert "Locked spans" in out and "Diff" in out and "Before" in out and "After" in out
+ assert "Rewrote" in out
+ assert "Smith (2020)" in out and "47%" in out
+ # The standard terminal render must not also run.
+ assert "--- Original ---" not in out
+ assert "humanization complete (" not in out
+
+
+def test_cli_html_json_envelope(monkeypatch, capsys) -> None:
+ _patch_cli(monkeypatch, _EditRW(), {AI: 0.80, EDITED: 0.20})
+ rc = main(["--tier", "lite", "--html", "--json", "--max-iters", "1", "--best-of", "1", AI])
+ assert rc == 0
+ payload = json.loads(capsys.readouterr().out)
+ assert payload["format"] == "untell-html"
+ assert payload["version"] == 1
+ assert payload["html"].startswith("")
+ assert payload["diff"]["format"] == "untell-diff"
+ assert payload["rewrote"] is True and payload["seed"] is not None
+ assert payload["pre"]["max"] == 0.80 and payload["post"]["max"] == 0.20
+ spans = [row["span"] for row in payload["diff"]["locked_spans"]]
+ assert "Smith (2020)" in spans and "47%" in spans
+ assert payload["diff"]["locks_preserved"] == len(spans) == 2
+
+
+def test_cli_html_json_envelope_no_change(monkeypatch, capsys) -> None:
+ _patch_cli(monkeypatch, _IdentityRW(), {}, default=0.8)
+ rc = main(["--tier", "lite", "--html", "--json", "--max-iters", "1", "--best-of", "1", AI])
+ assert rc == 0
+ payload = json.loads(capsys.readouterr().out)
+ assert payload["rewrote"] is False
+ assert payload["diff"]["changed"] is False
+ assert "Returned unchanged" in payload["html"] or "no change" in payload["html"]
+
+
+def test_cli_html_json_error_path_holds(monkeypatch, capsys) -> None:
+ """`--html --json` with no input must answer JSON and exit 2, like the other modes."""
+ _patch_cli(monkeypatch, _IdentityRW(), {})
+ rc = main(["--tier", "lite", "--html", "--json", " "])
+ assert rc == 2
+ parsed = json.loads(capsys.readouterr().out)
+ assert "error" in parsed
+
+
+def test_cli_html_reports_rewriter_failure(monkeypatch, capsys) -> None:
+ class _BrokenRW:
+ name = "broken"
+
+ def available(self):
+ return True
+
+ def rewrite(self, text, score_result, threshold=0.30):
+ raise RuntimeError("simulated rewriter failure")
+
+ _patch_cli(monkeypatch, _BrokenRW(), {}, default=0.9)
+ rc = main(["--tier", "lite", "--html", "--max-iters", "1", "--best-of", "1", AI])
+ assert rc == 1
+ out = capsys.readouterr().out
+ assert "ERROR" in out and "rewriter failed" in out
diff --git a/untell/rich_output.py b/untell/rich_output.py
index 551d41b3..5cdecc79 100644
--- a/untell/rich_output.py
+++ b/untell/rich_output.py
@@ -523,3 +523,242 @@ def progress_iteration(current: int, total: int, tier: str, score: float | None
score_part = f" P(AI)={score:.2f}" if score is not None else ""
_CONSOLE.print(f" [dim]→ Iteration {current}/{total}[/] tier={tier}{score_part}")
return None
+
+
+# ---------------------------------------------------------------------------
+# HTML report (`untell humanize --html`, issue #30)
+# ---------------------------------------------------------------------------
+#
+# A self-contained, deterministic HTML rendering of a humanize loop run. Unlike
+# the rich terminal views this module also drives, the artifact is meant to be
+# saved and shared, so it carries everything a reader needs to trust the run:
+# before/after text, pre/post scores, seed, whether it rewrote, the unified
+# --diff payload and — via the explain machinery the loop's own lock builds on —
+# a per-span annotation of every frozen fact and why it was frozen.
+#
+# Determinism: the same inputs return byte-identical output. There is no
+# timestamp, no random, no process-dependent value, and the CSS is inlined, so
+# two runs on the same text/seed produce two files that diff to nothing.
+#
+# Escaping is load-bearing, not cosmetic. Every value that came from the user's
+# text — original, final, each locked span, rules, rationale, warning — passes
+# through `html.escape`, so text that *is* attacker-shaped markup (a "