"""Regression tests for issue #1447 — markdown heading visual hierarchy. Cygnus reported (Discord, May 1 2026, relayed by @AvidFuturist): "Headings seem to be missing across the board in Hermes. They're there, but all plaintext. They get lost so easily in all the plaintext." Pre-fix sizes (smaller-than-or-equal to body 14px): h1 18px, h2 16px, h3 14px (= body), h4 13px, h5 12px, h6 11px. Post-fix sizes (clear hierarchy above body 14px): h1 24px, h2 20px, h3 17px, h4 15px, h5 14px (uppercase + tracked), h6 13px (uppercase + tracked + muted). These tests pin: - Each heading level has a meaningful size delta from the body and from the next-deeper level - h1 and h2 carry a bottom border for "section title" affordance - h5 and h6 carry uppercase + letter-spacing for "label-style" affordance - The .preview-md (file preview pane) sizes match .msg-body so a markdown file preview and a chat message look the same - The data-font-size small/large overrides scale proportionally """ from __future__ import annotations import re from pathlib import Path REPO = Path(__file__).resolve().parent.parent CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8") def _font_size(scope: str, level: str) -> int: """Extract the integer font-size (px) for the BARE ` ` selector. Anchors at the start of a line (after whitespace) so the data-font-size overrides like `[data-font-size="small"] .msg-body h1` are not matched. """ # Match `^ {...font-size:Npx...}` (whole rule on one line) pat = re.compile( rf"^\s*{re.escape(scope)}\s+{level}\s*\{{[^}}]*font-size:\s*(\d+)px", re.M, ) m = pat.search(CSS) assert m, f"font-size not found for `{scope} {level}` (line-anchored bare selector)" return int(m.group(1)) # ── Hierarchy: each level larger than the next ─────────────────────────────── def test_msg_body_heading_sizes_form_clear_hierarchy(): """h1 > h2 > h3 > h4 in size, with at least 2px between adjacent levels. h5 and h6 use uppercase + letter-spacing rather than larger size for their visual distinction (they're "label-style" headings), so they don't strictly need to be larger than h4 — but they must still be at least body size (14px). """ h1 = _font_size(".msg-body", "h1") h2 = _font_size(".msg-body", "h2") h3 = _font_size(".msg-body", "h3") h4 = _font_size(".msg-body", "h4") h5 = _font_size(".msg-body", "h5") h6 = _font_size(".msg-body", "h6") assert h1 >= h2 + 3, f"h1 ({h1}) must be at least 3px larger than h2 ({h2})" assert h2 >= h3 + 2, f"h2 ({h2}) must be at least 2px larger than h3 ({h3})" assert h3 >= h4 + 2, f"h3 ({h3}) must be at least 2px larger than h4 ({h4})" # Body is 14px. assert h4 >= 14, f"h4 ({h4}) must not be smaller than body (14px)" assert h5 >= 14, f"h5 ({h5}) must not be smaller than body (14px) — uppercase compensates" assert h6 >= 13, f"h6 ({h6}) must not be much smaller than body (uppercase compensates) — got {h6}" # h3 must be visibly above body — Cygnus's specific complaint. assert h3 > 14, f"h3 ({h3}) must be larger than body (14px) so it is visibly a heading" def test_msg_body_h1_and_h2_have_bottom_border(): """h1 and h2 carry a bottom border for visible 'section title' affordance. This mirrors GitHub/Notion convention and the existing .preview-md h1 rule. """ h1_match = re.search( r"\.msg-body\s+h1\s*\{[^}]*border-bottom:\s*1px\s+solid", CSS, ) assert h1_match, ".msg-body h1 must have border-bottom: 1px solid" h2_match = re.search( r"\.msg-body\s+h2\s*\{[^}]*border-bottom:\s*1px\s+solid", CSS, ) assert h2_match, ".msg-body h2 must have border-bottom: 1px solid" def test_msg_body_h5_and_h6_use_label_style_affordance(): """h5 and h6 use uppercase + letter-spacing rather than larger sizes.""" h5_match = re.search( r"\.msg-body\s+h5\s*\{[^}]*text-transform:\s*uppercase[^}]*letter-spacing:", CSS, ) assert h5_match, ".msg-body h5 must have text-transform:uppercase + letter-spacing" h6_match = re.search( r"\.msg-body\s+h6\s*\{[^}]*text-transform:\s*uppercase[^}]*letter-spacing:", CSS, ) assert h6_match, ".msg-body h6 must have text-transform:uppercase + letter-spacing" def test_msg_body_headings_use_strong_color_and_bold_weight(): """All headings must use bold weight (700) and strong color, not light grey.""" base_match = re.search( r"\.msg-body\s+h1,\s*\.msg-body\s+h2,\s*\.msg-body\s+h3,\s*\.msg-body\s+h4," r"\s*\.msg-body\s+h5,\s*\.msg-body\s+h6\s*\{[^}]*font-weight:\s*700", CSS, ) assert base_match, "Combined .msg-body h1..h6 selector must set font-weight:700" # Color must reference --strong (with --text fallback). color_match = re.search( r"\.msg-body\s+h1,\s*\.msg-body\s+h2,\s*\.msg-body\s+h3,\s*\.msg-body\s+h4," r"\s*\.msg-body\s+h5,\s*\.msg-body\s+h6\s*\{[^}]*color:\s*var\(--strong", CSS, ) assert color_match, "Combined heading selector must use color:var(--strong, ...)" # ── preview-md sync: chat and file preview render headings the same ────────── def test_preview_md_heading_sizes_match_msg_body(): """Per the issue's 'companion fix' note: .preview-md heading sizes must mirror .msg-body so a markdown file preview and a chat message look identical.""" for level in ("h1", "h2", "h3", "h4", "h5", "h6"): msg_size = _font_size(".msg-body", level) preview_size = _font_size(".preview-md", level) assert msg_size == preview_size, ( f".preview-md {level} ({preview_size}px) must match .msg-body {level} ({msg_size}px)" ) def test_preview_md_has_h4_h5_h6_rules(): """Pre-fix .preview-md only had h1-h3 rules. Post-fix must have all six.""" for level in ("h4", "h5", "h6"): match = re.search(rf"\.preview-md\s+{level}\s*\{{[^}}]*font-size:\s*\d+px", CSS) assert match, f".preview-md {level} rule missing" # ── data-font-size scaling: small/large stay proportional ──────────────────── def test_data_font_size_small_overrides_scale_with_new_defaults(): """The data-font-size='small' h1 override must NOT be ≤ body 14px (the old 15px h1 in small mode was effectively the same as body — the bug Cygnus complained about, just at small font-size). """ # Walk h1..h6 small overrides small_h_sizes = {} for level in ("h1", "h2", "h3", "h4", "h5", "h6"): m = re.search( rf'data-font-size="small"\]\s*\.msg-body\s+{level}\s*\{{[^}}]*font-size:\s*(\d+)px', CSS, ) if m: small_h_sizes[level] = int(m.group(1)) # Must have all six. assert set(small_h_sizes.keys()) == {"h1", "h2", "h3", "h4", "h5", "h6"}, ( f"data-font-size='small' missing override for some levels: got {sorted(small_h_sizes)}" ) # Body in small mode is 12px. assert small_h_sizes["h1"] >= 18, f"small h1 too small: {small_h_sizes['h1']}" assert small_h_sizes["h2"] >= 16, f"small h2 too small: {small_h_sizes['h2']}" assert small_h_sizes["h3"] >= 14, f"small h3 too small: {small_h_sizes['h3']}" # Hierarchy preserved. assert small_h_sizes["h1"] > small_h_sizes["h2"] assert small_h_sizes["h2"] > small_h_sizes["h3"] assert small_h_sizes["h3"] > small_h_sizes["h4"] def test_data_font_size_large_overrides_scale_with_new_defaults(): """The data-font-size='large' h1 override must scale up proportionally with the new defaults.""" large_h_sizes = {} for level in ("h1", "h2", "h3", "h4", "h5", "h6"): m = re.search( rf'data-font-size="large"\]\s*\.msg-body\s+{level}\s*\{{[^}}]*font-size:\s*(\d+)px', CSS, ) if m: large_h_sizes[level] = int(m.group(1)) assert set(large_h_sizes.keys()) == {"h1", "h2", "h3", "h4", "h5", "h6"} # Body in large mode is 16px. h1 must be a meaningful step above. assert large_h_sizes["h1"] >= 26, f"large h1 too small: {large_h_sizes['h1']}" assert large_h_sizes["h2"] >= 22, f"large h2 too small: {large_h_sizes['h2']}" assert large_h_sizes["h3"] >= 19, f"large h3 too small: {large_h_sizes['h3']}" # Hierarchy preserved. assert large_h_sizes["h1"] > large_h_sizes["h2"] assert large_h_sizes["h2"] > large_h_sizes["h3"] assert large_h_sizes["h3"] > large_h_sizes["h4"] # ── Specific values from the issue spec ────────────────────────────────────── def test_specific_heading_sizes_match_issue_spec(): """Pin the exact spec'd sizes so unrelated CSS edits don't drift them.""" assert _font_size(".msg-body", "h1") == 24 assert _font_size(".msg-body", "h2") == 20 assert _font_size(".msg-body", "h3") == 17 assert _font_size(".msg-body", "h4") == 15 # h5 and h6 use uppercase, so size is at body or just below. assert _font_size(".msg-body", "h5") == 14 assert _font_size(".msg-body", "h6") == 13