diff --git a/booth/templates/base.html b/booth/templates/base.html
index ddf126f..e1bdb1e 100644
--- a/booth/templates/base.html
+++ b/booth/templates/base.html
@@ -730,7 +730,10 @@
.markdown-body{color:var(--text-body);line-height:1.65;font-size:15px;overflow-wrap:break-word}
.markdown-body h1,.markdown-body h2,.markdown-body h3{color:var(--text-heading);line-height:1.25;
letter-spacing:var(--tracking-h);margin:1.6em 0 .5em;font-weight:650}
- .markdown-body h1{font-size:1.6em}.markdown-body h2{font-size:1.3em}.markdown-body h3{font-size:1.08em;font-weight:600}
+ .markdown-body h1{font-size:1.73em}.markdown-body h2{font-size:1.44em}.markdown-body h3{font-size:1.2em;font-weight:600}
+ /* as S4: headings step by ~1.2 each (h3 was 1.08 over its body); prose reads at a
+ book's measure, while pre and tables keep the full width where they scroll */
+ .markdown-body > :is(p,ul,ol,blockquote,dl,h1,h2,h3,h4,h5,h6){max-width:72ch}
.markdown-body h1,.markdown-body h2{border-bottom:1px solid var(--border-subtle);padding-bottom:.3em}
.markdown-body :first-child{margin-top:0}
.markdown-body p,.markdown-body ul,.markdown-body ol,.markdown-body blockquote{margin:.7em 0}
diff --git a/docs/contracts/as_antislop.contract.md b/docs/contracts/as_antislop.contract.md
index a94b072..49929ea 100644
--- a/docs/contracts/as_antislop.contract.md
+++ b/docs/contracts/as_antislop.contract.md
@@ -109,9 +109,17 @@ The run found that the Booth is sound on desktop and has a set of problems a vie
- `.vname` already ellipsises; the detector measures the clipped inner width.
- The filmstrip clips its next frame at the edge on purpose: the clipped frame is the "there is more" cue of a horizontal scroller.
-## S4 to S6
+## S4 — reading measure
+
+**The rule:** a rendered document reads at a book's measure and says its structure with size.
+
+- **Measure.** Prose blocks in `.markdown-body` (paragraphs, lists, block quotes, headings, definition lists) are at most `72ch` wide. Wide blocks (`pre`, tables) keep the full width, where they scroll.
+ - *Falsifiable:* on the doc view at 1280 wide, a long paragraph measures at most 76 characters of its own font across: `test_doc_prose_reads_at_a_book_measure`.
+- **Heading scale.** h3 : body, h2 : h3 and h1 : h2 are each at least 1.18 (h3 `1.2em`, h2 `1.44em`, h1 `1.73em`). Before this, h3 was `1.08em` over its body.
+ - *Falsifiable:* `test_doc_headings_step_by_size`.
+
+## S5 and S6
These are added as each slice is staged:
-- S4, reading measure;
- S5, interaction and screen readers;
- S6, the operator's rulings (tagline, needs-you stripe, matte brand dot, the Wipe-now stripe on `::before`).
diff --git a/tests/mutations/antislop.toml b/tests/mutations/antislop.toml
index 8c57a7e..2396597 100644
--- a/tests/mutations/antislop.toml
+++ b/tests/mutations/antislop.toml
@@ -313,3 +313,19 @@ file = "booth/templates/base.html"
test = "tests/test_antislop_browser.py::test_board_head_stays_compact_on_a_phone"
old = """ .bench-head{flex-wrap:wrap}.bench-title{white-space:nowrap}.bench-note{flex:1 1 100%}}"""
new = """ }"""
+
+# ---- S4: reading measure
+
+[[mutation]]
+label = "S4 prose runs the full width again"
+file = "booth/templates/base.html"
+test = "tests/test_antislop_browser.py::test_doc_prose_reads_at_a_book_measure"
+old = """ .markdown-body > :is(p,ul,ol,blockquote,dl,h1,h2,h3,h4,h5,h6){max-width:72ch}"""
+new = """"""
+
+[[mutation]]
+label = "S4 h3 flat against its body again (1.08em)"
+file = "booth/templates/base.html"
+test = "tests/test_antislop_browser.py::test_doc_headings_step_by_size"
+old = """.markdown-body h3{font-size:1.2em;font-weight:600}"""
+new = """.markdown-body h3{font-size:1.08em;font-weight:600}"""
diff --git a/tests/test_antislop_browser.py b/tests/test_antislop_browser.py
index f22e4d7..b496acf 100644
--- a/tests/test_antislop_browser.py
+++ b/tests/test_antislop_browser.py
@@ -115,3 +115,42 @@ def test_review_header_is_one_line_on_a_phone(browser, live):
assert page.locator(".topbar .tagline").evaluate("e => getComputedStyle(e).display") != "none", \
"the Desk keeps its tagline (negative control)"
page.close()
+
+
+# ---- S4: reading measure --------------------------------------------------------
+
+_DOC = "# The Booth\n\n## What it is\n\n### Stated once\n\n" + ("The Booth is the fleet's operator-review surface. Agents post work; the operator looks at it, judges it, and the judgment gets back to the agent. " * 4) + "\n"
+
+
+def _doc_page(browser, live):
+ base, root = live
+ d = root / "d"
+ d.mkdir()
+ (d / "doc.md").write_text(_DOC)
+ page = browser.new_page(viewport={"width": 1280, "height": 900})
+ page.goto(f"{base}/b/d/view?f=doc.md", wait_until="load")
+ return page
+
+
+def test_doc_prose_reads_at_a_book_measure(browser, live):
+ page = _doc_page(browser, live)
+ chars = page.locator(".markdown-body p").first.evaluate("""p => {
+ const probe = document.createElement('span');
+ probe.textContent = '0'.repeat(100);
+ probe.style.cssText = 'position:absolute;visibility:hidden;white-space:nowrap';
+ p.appendChild(probe);
+ const ch = probe.getBoundingClientRect().width / 100;
+ probe.remove();
+ return p.getBoundingClientRect().width / ch;
+ }""")
+ assert chars <= 76, f"a paragraph runs {chars:.0f} characters across"
+ page.close()
+
+
+def test_doc_headings_step_by_size(browser, live):
+ page = _doc_page(browser, live)
+ size = lambda sel: page.locator(f".markdown-body {sel}").first.evaluate("e => parseFloat(getComputedStyle(e).fontSize)")
+ body, h3, h2, h1 = size("p"), size("h3"), size("h2"), size("h1")
+ for lo, hi, name in ((body, h3, "h3:body"), (h3, h2, "h2:h3"), (h2, h1, "h1:h2")):
+ assert hi / lo >= 1.18, f"{name} is {hi / lo:.2f} ({hi}px over {lo}px)"
+ page.close()