Files
hermes-webui/tests/test_issue1443_ime_helper_promotion.py
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

184 lines
7.3 KiB
Python

"""Regression tests for issue #1443 — promote `_isImeEnter` to all Safari-affected Enter guards.
PR #1441 (v0.50.264) widened the chat composer's IME-Enter guard from `e.isComposing`
to a `_isImeEnter(e)` helper in `static/boot.js`. The helper combines three signals
(`e.isComposing || e.keyCode === 229 || _imeComposing`) so it catches the Safari race
where the committing keydown for an IME composition fires AFTER `compositionend` with
`isComposing=false`.
Six other Enter-input handlers were left on the original `e.isComposing` guard:
- `static/sessions.js` — session rename (~line 1693)
- `static/sessions.js` — project create (~line 1987)
- `static/sessions.js` — project rename (~line 2015)
- `static/ui.js` — app dialog (confirm/prompt) (~line 2482)
- `static/ui.js` — message edit (Enter to save) (~line 4106)
- `static/ui.js` — workspace rename (~line 5007)
Issue #1443 promotes the helper to `window._isImeEnter` (defined in boot.js) and
replaces the 6 `e.isComposing` guards with `window._isImeEnter(e)`. These tests pin
each site so a future cleanup that strips the windowed call trips a test.
The state-free part of the helper (`e.isComposing || e.keyCode === 229`) is what the
6 non-composer sites rely on — it works for any focused input on Safari without needing
per-input composition listeners or a per-input flag.
"""
import pathlib
import re
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text(encoding="utf-8")
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
SESSIONS_JS = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
def _windowed_guard(ev: str) -> str:
"""Return regex for `if(window._isImeEnter && window._isImeEnter(<ev>))` shape."""
return (
rf"if\s*\(\s*window\._isImeEnter\s*&&\s*"
rf"window\._isImeEnter\s*\(\s*{ev}\s*\)\s*\)\s*"
)
# ── Promotion: `window._isImeEnter` is exported from boot.js ─────────────────
def test_isimeenter_helper_is_exposed_on_window():
"""boot.js must attach `_isImeEnter` to `window` so other modules can reuse it."""
assert re.search(
r"window\._isImeEnter\s*=\s*_isImeEnter\s*;?",
BOOT_JS,
), (
"boot.js must export `window._isImeEnter = _isImeEnter` so "
"static/sessions.js and static/ui.js can call the same Safari-aware "
"helper without duplicating the IIFE per input (issue #1443)."
)
# ── No raw `e.isComposing` guards remain in the 6 non-composer sites ─────────
def test_no_isComposing_guards_remain_in_sessions_js():
"""sessions.js must not contain a raw `e.isComposing` Enter-guard anymore."""
leaks = re.findall(r"\b(?:e2?)\.isComposing\b", SESSIONS_JS)
assert not leaks, (
f"sessions.js still contains {len(leaks)} raw `e.isComposing` guard(s); "
f"all Enter-input handlers should route through window._isImeEnter "
f"(issue #1443)."
)
def test_no_isComposing_guards_remain_in_ui_js():
"""ui.js must not contain a raw `e.isComposing` Enter-guard anymore."""
leaks = re.findall(r"\b(?:e2?)\.isComposing\b", UI_JS)
assert not leaks, (
f"ui.js still contains {len(leaks)} raw `e.isComposing` guard(s); "
f"all Enter-input handlers should route through window._isImeEnter "
f"(issue #1443)."
)
# ── Each of the 6 specific sites uses the promoted helper ────────────────────
def test_session_rename_uses_windowed_helper():
"""Session rename (sessions.js ~1693) must use window._isImeEnter."""
# The session rename block: `inp.onkeydown=e2=>{ if(e2.key==='Enter'){ <guard> ...
pattern = re.compile(
r"inp\.onkeydown\s*=\s*e2\s*=>\s*\{\s*"
r"if\s*\(\s*e2\.key\s*===\s*'Enter'\s*\)\s*\{\s*"
+ _windowed_guard("e2"),
re.DOTALL,
)
assert pattern.search(SESSIONS_JS), (
"Session rename Enter handler in static/sessions.js must use "
"window._isImeEnter(e2) (issue #1443)."
)
def test_project_create_and_rename_use_windowed_helper():
"""Project create + project rename (sessions.js ~1987 and ~2015) both use window._isImeEnter."""
# Both project blocks share the shape `inp.onkeydown=(e)=>{...}` (note the parens).
pattern = re.compile(
r"inp\.onkeydown\s*=\s*\(\s*e\s*\)\s*=>\s*\{\s*"
r"if\s*\(\s*e\.key\s*===\s*'Enter'\s*\)\s*\{\s*"
+ _windowed_guard("e"),
re.DOTALL,
)
matches = pattern.findall(SESSIONS_JS)
assert len(matches) >= 2, (
f"Project create AND project rename Enter handlers in static/sessions.js "
f"must both use window._isImeEnter(e); found {len(matches)} of 2 expected "
f"(issue #1443)."
)
def test_app_dialog_uses_windowed_helper():
"""App dialog confirm/prompt (ui.js ~2482) must use window._isImeEnter."""
# Pattern: `document.addEventListener('keydown',e=>{ ... if(e.key==='Enter'){
# if(window._isImeEnter && window._isImeEnter(e)) return;`
pattern = re.compile(
r"document\.addEventListener\(\s*'keydown'\s*,\s*e\s*=>\s*\{[\s\S]*?"
r"if\s*\(\s*e\.key\s*===\s*'Enter'\s*\)\s*\{\s*"
+ _windowed_guard("e"),
re.DOTALL,
)
assert pattern.search(UI_JS), (
"App dialog confirm/prompt Enter handler in static/ui.js must use "
"window._isImeEnter(e) (issue #1443)."
)
def test_message_edit_uses_windowed_helper():
"""Message edit Enter-to-save (ui.js ~4106) must use window._isImeEnter."""
# Pattern: `ta.addEventListener('keydown', e => { if(e.key==='Enter' && !e.shiftKey)
# { if(window._isImeEnter && window._isImeEnter(e)) return;`
pattern = re.compile(
r"ta\.addEventListener\(\s*'keydown'\s*,\s*e\s*=>\s*\{\s*"
r"if\s*\(\s*e\.key\s*===\s*'Enter'\s*&&\s*!\s*e\.shiftKey\s*\)\s*\{\s*"
+ _windowed_guard("e"),
re.DOTALL,
)
assert pattern.search(UI_JS), (
"Message edit Enter-to-save handler in static/ui.js must use "
"window._isImeEnter(e) (issue #1443)."
)
def test_workspace_rename_uses_windowed_helper():
"""Workspace rename (ui.js ~5007) must use window._isImeEnter."""
# Pattern: `inp.onkeydown=(e2)=>{ if(e2.key==='Enter'){ if(window._isImeEnter && ...
pattern = re.compile(
r"inp\.onkeydown\s*=\s*\(\s*e2\s*\)\s*=>\s*\{\s*"
r"if\s*\(\s*e2\.key\s*===\s*'Enter'\s*\)\s*\{\s*"
+ _windowed_guard("e2"),
re.DOTALL,
)
assert pattern.search(UI_JS), (
"Workspace rename Enter handler in static/ui.js must use "
"window._isImeEnter(e2) (issue #1443)."
)
# ── Helper still has the 3-guard shape (regression on PR #1441) ──────────────
def test_isimeenter_still_has_three_guards():
"""The helper itself must still combine all three guards. Promotion to `window`
must not have stripped any of them."""
pattern = re.compile(
r"function\s+_isImeEnter\s*\(\s*e\s*\)\s*\{[^}]*"
r"e\.isComposing"
r"[^}]*"
r"e\.keyCode\s*===\s*229"
r"[^}]*"
r"_imeComposing"
r"[^}]*\}",
re.DOTALL,
)
assert pattern.search(BOOT_JS), (
"_isImeEnter must still combine e.isComposing, keyCode===229, and "
"_imeComposing flag after promotion to window (PR #1441 + issue #1443)."
)