Files
hermes-webui/tests/test_issue1447_heading_hierarchy.py
T
nesquena-hermes c73f2ff387 v0.50.264 polish followups: i18n parity + assistant-output readability
Closes #1442 (server-side _LOGIN_LOCALE missing ja/pt/ko)
Closes #1443 (promote _isImeEnter helper to 6 other Safari Enter guards)
Closes #1446 (glued-bold-heading lift for LLM thinking-block output)
Closes #1447 (markdown heading visual hierarchy in chat messages)

All four issues were filed by the Opus pre-release advisor on the v0.50.264 batch
or by Cygnus via Discord (relayed by @AvidFuturist, May 1 2026). They share a
common shape — narrow, well-scoped, independent of each other, all adding
regression tests.

== #1442: _LOGIN_LOCALE parity (api/routes.py + static/i18n.js) ==

Added entries for ja/pt/ko to the server-side _LOGIN_LOCALE dict that renders
the localized login page BEFORE the JS i18n bundle loads. With v0.50.264
shipping Japanese as the 8th built-in locale, ja/pt/ko users were seeing the
English login page even with their language preference set.

While auditing static/i18n.js for English leakage, also fixed:
  - ko: 10 user-facing login/sign-out/password keys still in English
  - es: 3 sign-out/auth-disabled keys still in English

Tests: tests/test_login_locale_parity.py (20 tests) — pins both invariants:
  (a) every locale in i18n.js LOCALES has a matching _LOGIN_LOCALE entry
  (b) every locale's login-flow keys (13 of them) are translated, not English

== #1443: window._isImeEnter promotion ==

PR #1441 fixed the Safari IME-composition Enter race in the chat composer
(`#msg`) by widening the guard from `e.isComposing` to a `_isImeEnter(e)`
helper that combines three signals (isComposing || keyCode===229 ||
_imeComposing flag). Six other Enter-input handlers were left on the original
narrow guard and would still drop IME composition Enters on Safari for
Japanese/Chinese/Korean users.

Promoted the helper to `window._isImeEnter` (defined in static/boot.js) and
replaced the `e.isComposing` guards at all six sites:

  - static/sessions.js: session rename, project create, project rename
  - static/ui.js: app dialog (confirm/prompt), message edit, workspace rename

The state-free part of the helper (`isComposing || keyCode===229`) handles
Safari's race for any focused input without needing per-input composition
listeners — only `#msg` keeps the local `_imeComposing` flag.

Tests:
  - tests/test_issue1443_ime_helper_promotion.py (9 tests) — pins each site
    + verifies no raw `e.isComposing` Enter-guards remain in sessions.js/ui.js
  - tests/test_ime_composition.py — alternation regex extended to accept
    the windowed helper form (loosen-test-on-shape-change pattern from
    v0.50.264 reflection notes)

== #1446: glued-bold-heading lift (static/ui.js renderMd + Python mirror) ==

LLMs in thinking/reasoning mode emit "section headers" glued to the end of the
previous paragraph with no whitespace:

    Para 1 text.**Heading to Para 2**

    Para 2 text.**Heading to Para 3**

The renderer correctly produces inline `<strong>` per CommonMark, but it looks
like trailing emphasis on the body text rather than a section break. Cygnus
reported this as "Markdown feedback 2 of 3."

Added a single regex pre-pass in renderMd():

    s.replace(/([.!?])\*\*([^*\n]{1,80})\*\*\n\n/g, '$1\n\n**$2**\n\n')

Constraints chosen to avoid false positives:
  - Trigger only on `[.!?]` IMMEDIATELY before `**` (no space) — almost always
    an LLM-glued heading, not intentional emphasis
  - Inner text ≤80 chars, no `*` or newline (single-line only)
  - Trailing `\n\n` required — preserves "this is **important** to know."
    mid-paragraph emphasis untouched
  - Position: after rawPreStash restore, before fence_stash restore — fenced
    code blocks stay protected (their content is `\x00P` / `\x00F` tokens
    when the lift runs)

Mirrored in tests/test_sprint16.py render_md() so both stay in sync.

Tests: tests/test_issue1446_glued_heading_lift.py (17 tests, 5 of which drive
the actual ui.js renderMd via node) — covers all 3 trigger forms (.!?), all 4
preserve-emphasis cases the issue spec'd, fenced/inline code protection,
chained glued headings, source-level position pin, regex shape pin.

== #1447: markdown heading visual hierarchy (static/style.css) ==

Pre-fix sizes in `.msg-body`:
  h1 18px, h2 16px, h3 14px (= body), h4 13px, h5 12px, h6 11px

So h3 was indistinguishable from body and h4/h5/h6 were SMALLER than body.
Cygnus's report: "Markdown feedback 3 of 3 — Headings seem to be missing
across the board in Hermes. They're there, but all plaintext."

New sizes:
  h1 24px (border-bottom)  h2 20px (border-bottom)  h3 17px  h4 15px
  h5 14px (uppercase, tracked)  h6 13px (uppercase, tracked, muted)

All headings now `font-weight:700` + `color:var(--strong)` for stronger ink.
h5/h6 use uppercase + letter-spacing for "label-style" affordance instead
of being smaller-than-body.

Synced .preview-md (file preview pane) to match exactly so a markdown file
preview and a chat message render identically. Added missing h4/h5/h6 rules
to .preview-md (it only had h1-h3 before).

Updated data-font-size="small"/"large" h1-h6 overrides to scale
proportionally with the new defaults. Hierarchy preserved at all three
font-size settings.

Tests: tests/test_issue1447_heading_hierarchy.py (9 tests) — pins the size
hierarchy, the bottom borders on h1/h2, the uppercase affordance on h5/h6,
the .preview-md sync, and the small/large override scaling.

== Verification ==

  pytest tests/ -q                                  → 3748 passed (+56 new)
  bash ~/WebUI/scripts/run-browser-tests.sh         → 20 + 11 PASS
  bash ~/WebUI/scripts/webui_qa_agent.sh 8789       → 23/23 PASS

Visual confirmation in browser at port 8789:
  - Heading hierarchy clearly visible at all 6 levels
  - Glued-bold lift produces separate paragraphs as designed
  - window._isImeEnter accessible from any module after boot.js
  - Login page renders ja/pt/ko strings correctly (curl -s /login)
2026-05-02 04:19:28 +00:00

210 lines
9.1 KiB
Python

"""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 `<scope> <level>` 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 `^<whitespace><scope> <level>{...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