diff --git a/api/routes.py b/api/routes.py index d3cd0d68..8027e5fe 100644 --- a/api/routes.py +++ b/api/routes.py @@ -906,6 +906,36 @@ _LOGIN_LOCALE = { "invalid_pw": "\u5bc6\u78bc\u932f\u8aa4", "conn_failed": "\u9023\u63a5\u5931\u6557", }, + # Strings mirror static/i18n.js login_* keys for the corresponding locale. + # See issue #1442. When adding a new locale to LOCALES in i18n.js, also add + # the matching entry here — tests/test_login_locale_parity.py enforces this. + "ja": { + "lang": "ja-JP", + "title": "\u30b5\u30a4\u30f3\u30a4\u30f3", + "subtitle": "\u30d1\u30b9\u30ef\u30fc\u30c9\u3092\u5165\u529b\u3057\u3066\u7d9a\u884c", + "placeholder": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "btn": "\u30b5\u30a4\u30f3\u30a4\u30f3", + "invalid_pw": "\u30d1\u30b9\u30ef\u30fc\u30c9\u304c\u7121\u52b9\u3067\u3059", + "conn_failed": "\u63a5\u7d9a\u5931\u6557", + }, + "pt": { + "lang": "pt-BR", + "title": "Entrar", + "subtitle": "Digite sua senha para continuar", + "placeholder": "Senha", + "btn": "Entrar", + "invalid_pw": "Senha inv\u00e1lida", + "conn_failed": "Falha na conex\u00e3o", + }, + "ko": { + "lang": "ko-KR", + "title": "\ub85c\uadf8\uc778", + "subtitle": "\uacc4\uc18d\ud558\ub824\uba74 \ube44\ubc00\ubc88\ud638\ub97c \uc785\ub825\ud558\uc138\uc694", + "placeholder": "\ube44\ubc00\ubc88\ud638", + "btn": "\ub85c\uadf8\uc778", + "invalid_pw": "\ube44\ubc00\ubc88\ud638\uac00 \uc62c\ubc14\ub974\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4", + "conn_failed": "\uc5f0\uacb0 \uc2e4\ud328", + }, } diff --git a/static/boot.js b/static/boot.js index 7c543a20..534bc9c5 100644 --- a/static/boot.js +++ b/static/boot.js @@ -813,6 +813,14 @@ $('msg').addEventListener('input',()=>{ // manual flag and reset it on the next tick to swallow that trailing Enter. // Also reset on blur so the flag can never get stuck in a true state if // compositionend never fires (focus loss with some IME implementations). +// +// The `_imeComposing` flag is bound to the chat composer (`#msg`); other +// inputs (session/project rename, app dialog, message edit, workspace rename) +// rely on the state-free `e.isComposing || e.keyCode === 229` part of +// `_isImeEnter`, which is sufficient for the Safari race because keyCode 229 +// is the canonical "still composing" signal regardless of which field is +// focused. Promote `_isImeEnter` to `window` so other modules can reuse it +// without duplicating the full IIFE per input (issue #1443). let _imeComposing=false; (()=>{const _c=$('msg');if(!_c)return; _c.addEventListener('compositionstart',()=>{_imeComposing=true;}); @@ -820,6 +828,7 @@ let _imeComposing=false; _c.addEventListener('blur',()=>{_imeComposing=false;}); })(); function _isImeEnter(e){return e.isComposing||e.keyCode===229||_imeComposing;} +window._isImeEnter=_isImeEnter; $('msg').addEventListener('keydown',e=>{ // Autocomplete navigation when dropdown is open const dd=$('cmdDropdown'); diff --git a/static/i18n.js b/static/i18n.js index 8e607a84..c19f142a 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -3146,10 +3146,10 @@ const LOCALES = { active_conversation_none: 'No active conversation selected.', active_conversation_meta: (title, count) => `${title} · ${count} message${count === 1 ? '' : 's'}`, settings_unsaved_changes: 'You have unsaved changes.', - sign_out_failed: 'Sign out failed: ', - disable_auth_confirm_title: 'Disable password protection', + sign_out_failed: 'Error al cerrar sesión: ', + disable_auth_confirm_title: 'Desactivar protección por contraseña', disable_auth_confirm_message: 'Anyone will be able to access this instance.', - auth_disabled: 'Auth disabled — password protection removed', + auth_disabled: 'Autenticación desactivada — protección por contraseña eliminada', disable_auth_failed: 'Failed to disable auth: ', bg_error_single: (title) => `"${title}" has encountered an error`, bg_error_multi: (count) => `${count} sessions have encountered an error`, @@ -6596,6 +6596,10 @@ const LOCALES = { voice_thinking: 'Thinking…', // TODO: translate voice_toggle: 'Voice input', // TODO: translate subagent_children: 'Subagent sessions', // TODO: translate + // login-flow keys (issue #1442) + sign_out_failed: 'Falha ao sair: ', + auth_disabled: 'Autenticação desativada — proteção por senha removida', + disable_auth_confirm_title: 'Desativar proteção por senha', }, ko: { _lang: 'ko', @@ -6979,15 +6983,15 @@ const LOCALES = { settings_saved: '설정 저장됨', settings_save_failed: '설정 저장 실패: ', settings_load_failed: '설정 로드 실패: ', - settings_saved_pw: 'Settings saved — password protection enabled and this browser stays signed in', - settings_saved_pw_updated: 'Settings saved — password updated', + settings_saved_pw: '설정이 저장되었습니다 — 비밀번호 보호가 활성화되었으며 이 브라우저는 로그인 상태로 유지됩니다', + settings_saved_pw_updated: '설정이 저장되었습니다 — 비밀번호가 업데이트되었습니다', // login page (used server-side via /api/i18n/login endpoint) login_title: '로그인', login_subtitle: '계속하려면 비밀번호를 입력하세요.', - login_placeholder: 'Password', - login_btn: 'Sign in', - login_invalid_pw: 'Invalid password', - login_conn_failed: 'Connection failed', + login_placeholder: '비밀번호', + login_btn: '로그인', + login_invalid_pw: '비밀번호가 올바르지 않습니다', + login_conn_failed: '연결 실패', // Sidebar & Tabs tab_chat: '채팅', tab_tasks: '작업', @@ -7043,7 +7047,7 @@ const LOCALES = { settings_desc_check_updates: 'WebUI 또는 Agent의 새 버전이 있으면 배너를 표시합니다. 백그라운드에서 주기적으로 git fetch를 실행합니다.', settings_desc_bot_name: 'UI 전체에 표시되는 Assistant 이름입니다. 기본값은 Hermes입니다.', settings_desc_password: '새 비밀번호를 설정하거나 변경하려면 입력하세요. 현재 설정을 유지하려면 비워 두세요.', - password_placeholder: 'Enter new password…', + password_placeholder: '새 비밀번호 입력…', disable_auth: '인증 비활성화', sign_out: '로그아웃', // Providers panel @@ -7308,10 +7312,10 @@ const LOCALES = { active_conversation_none: 'No active conversation selected.', active_conversation_meta: (title, count) => `${title} · ${count} message${count === 1 ? '' : 's'}`, settings_unsaved_changes: 'You have unsaved changes.', - sign_out_failed: 'Sign out failed: ', - disable_auth_confirm_title: 'Disable password protection', + sign_out_failed: '로그아웃 실패: ', + disable_auth_confirm_title: '비밀번호 보호 비활성화', disable_auth_confirm_message: 'Anyone will be able to access this instance.', - auth_disabled: 'Auth disabled — password protection removed', + auth_disabled: '인증 비활성화 — 비밀번호 보호가 제거되었습니다', disable_auth_failed: 'Failed to disable auth: ', bg_error_single: (title) => `"${title}" has encountered an error`, bg_error_multi: (count) => `${count} sessions have encountered an error`, diff --git a/static/sessions.js b/static/sessions.js index 51a9e7b4..ed678e5f 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1692,7 +1692,7 @@ function renderSessionListFromCache(){ }; inp.onkeydown=e2=>{ if(e2.key==='Enter'){ - if(e2.isComposing){return;} + if(window._isImeEnter&&window._isImeEnter(e2)){return;} e2.preventDefault(); e2.stopPropagation(); finish(true); @@ -1986,7 +1986,7 @@ function _startProjectCreate(bar, addBtn){ }; inp.onkeydown=(e)=>{ if(e.key==='Enter'){ - if(e.isComposing){return;} + if(window._isImeEnter&&window._isImeEnter(e)){return;} e.preventDefault(); finish(true); } @@ -2014,7 +2014,7 @@ function _startProjectRename(proj, chip){ }; inp.onkeydown=(e)=>{ if(e.key==='Enter'){ - if(e.isComposing){return;} + if(window._isImeEnter&&window._isImeEnter(e)){return;} e.preventDefault(); finish(true); } diff --git a/static/style.css b/static/style.css index 354fef81..bab926ba 100644 --- a/static/style.css +++ b/static/style.css @@ -35,18 +35,18 @@ /* Chat message body */ :root[data-font-size="small"] .msg-body { font-size: 12px; } :root[data-font-size="large"] .msg-body { font-size: 16px; } - :root[data-font-size="small"] .msg-body h1 { font-size: 15px; } - :root[data-font-size="large"] .msg-body h1 { font-size: 21px; } - :root[data-font-size="small"] .msg-body h2 { font-size: 13px; } - :root[data-font-size="large"] .msg-body h2 { font-size: 19px; } - :root[data-font-size="small"] .msg-body h3 { font-size: 12px; } - :root[data-font-size="large"] .msg-body h3 { font-size: 16px; } - :root[data-font-size="small"] .msg-body h4 { font-size: 11px; } - :root[data-font-size="large"] .msg-body h4 { font-size: 15px; } - :root[data-font-size="small"] .msg-body h5 { font-size: 11px; } - :root[data-font-size="large"] .msg-body h5 { font-size: 14px; } - :root[data-font-size="small"] .msg-body h6 { font-size: 10px; } - :root[data-font-size="large"] .msg-body h6 { font-size: 13px; } + :root[data-font-size="small"] .msg-body h1 { font-size: 21px; } + :root[data-font-size="large"] .msg-body h1 { font-size: 27px; } + :root[data-font-size="small"] .msg-body h2 { font-size: 17px; } + :root[data-font-size="large"] .msg-body h2 { font-size: 23px; } + :root[data-font-size="small"] .msg-body h3 { font-size: 15px; } + :root[data-font-size="large"] .msg-body h3 { font-size: 20px; } + :root[data-font-size="small"] .msg-body h4 { font-size: 13px; } + :root[data-font-size="large"] .msg-body h4 { font-size: 17px; } + :root[data-font-size="small"] .msg-body h5 { font-size: 12px; } + :root[data-font-size="large"] .msg-body h5 { font-size: 16px; } + :root[data-font-size="small"] .msg-body h6 { font-size: 11px; } + :root[data-font-size="large"] .msg-body h6 { font-size: 15px; } :root[data-font-size="small"] .msg-body code { font-size: 10.5px; } :root[data-font-size="large"] .msg-body code { font-size: 14.5px; } :root[data-font-size="small"] .msg-body pre code { font-size: 11px; } @@ -707,9 +707,17 @@ .msg-body{font-size:14px;line-height:1.75;color:var(--text);padding-left:30px;max-width:680px;overflow-wrap:anywhere;} .msg-body p{margin-bottom:10px;}.msg-body p:last-child{margin-bottom:0;} .msg-body ul,.msg-body ol{margin:6px 0 10px 20px;}.msg-body li{margin-bottom:3px;} - .msg-body h1,.msg-body h2,.msg-body h3,.msg-body h4,.msg-body h5,.msg-body h6{margin:16px 0 6px;font-weight:600;} - .msg-body h1{font-size:18px;}.msg-body h2{font-size:16px;}.msg-body h3{font-size:14px;} - .msg-body h4{font-size:13px;}.msg-body h5{font-size:12px;}.msg-body h6{font-size:11px;color:var(--muted);} + .msg-body h1,.msg-body h2,.msg-body h3,.msg-body h4,.msg-body h5,.msg-body h6{font-weight:700;color:var(--strong,var(--text));line-height:1.3;} + .msg-body h1{font-size:24px;margin:24px 0 12px;border-bottom:1px solid var(--border);padding-bottom:6px;} + .msg-body h2{font-size:20px;margin:22px 0 10px;border-bottom:1px solid var(--border);padding-bottom:4px;} + .msg-body h3{font-size:17px;margin:20px 0 8px;} + .msg-body h4{font-size:15px;margin:18px 0 8px;} + .msg-body h5{font-size:14px;margin:16px 0 6px;text-transform:uppercase;letter-spacing:0.04em;} + .msg-body h6{font-size:13px;margin:14px 0 6px;color:var(--muted);text-transform:uppercase;letter-spacing:0.04em;} + /* First-heading-of-message: collapse the top margin so it doesn't push the + entire message down (the row already has spacing). */ + .msg-body > h1:first-child,.msg-body > h2:first-child,.msg-body > h3:first-child, + .msg-body > h4:first-child,.msg-body > h5:first-child,.msg-body > h6:first-child{margin-top:0;} .msg-body strong{color:var(--strong);font-weight:600;}.msg-body em{color:var(--em);font-style:italic;} .msg-body code{font-family:"SF Mono","Fira Code",ui-monospace,monospace;font-size:12.5px;background:var(--code-inline-bg);padding:1px 5px;border-radius:4px;color:var(--code-text);} .msg-body pre{background:var(--code-bg);border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;margin:10px 0;} @@ -1096,9 +1104,13 @@ /* Markdown rendered preview */ .preview-md{font-size:13px;line-height:1.7;color:var(--text);flex:1;overflow-y:auto;min-height:0;} .preview-md p{margin-bottom:10px;}.preview-md p:last-child{margin-bottom:0;} - .preview-md h1{font-size:18px;font-weight:700;margin:16px 0 8px;color:var(--strong);border-bottom:1px solid var(--border);padding-bottom:6px;} - .preview-md h2{font-size:15px;font-weight:600;margin:14px 0 6px;color:var(--strong);} - .preview-md h3{font-size:13px;font-weight:600;margin:12px 0 4px;color:var(--strong);} + .preview-md h1,.preview-md h2,.preview-md h3,.preview-md h4,.preview-md h5,.preview-md h6{font-weight:700;color:var(--strong,var(--text));line-height:1.3;} + .preview-md h1{font-size:24px;margin:24px 0 12px;border-bottom:1px solid var(--border);padding-bottom:6px;} + .preview-md h2{font-size:20px;margin:22px 0 10px;border-bottom:1px solid var(--border);padding-bottom:4px;} + .preview-md h3{font-size:17px;margin:20px 0 8px;} + .preview-md h4{font-size:15px;margin:18px 0 8px;} + .preview-md h5{font-size:14px;margin:16px 0 6px;text-transform:uppercase;letter-spacing:0.04em;} + .preview-md h6{font-size:13px;margin:14px 0 6px;color:var(--muted);text-transform:uppercase;letter-spacing:0.04em;} .preview-md ul,.preview-md ol{margin:4px 0 10px 18px;}.preview-md li{margin-bottom:3px;} .preview-md code{font-family:"SF Mono",ui-monospace,monospace;font-size:11.5px;background:var(--code-inline-bg);padding:1px 5px;border-radius:4px;color:var(--code-text);} .preview-md pre{background:var(--code-bg);border:1px solid var(--border);border-radius:8px;padding:10px 12px;overflow-x:auto;margin:8px 0;} diff --git a/static/ui.js b/static/ui.js index 6df87a93..db76d20f 100644 --- a/static/ui.js +++ b/static/ui.js @@ -1629,6 +1629,31 @@ function renderMd(raw){ s=s.replace(/([^<]*?)<\/code>/gi,(_,t)=>'`'+t+'`'); s=s.replace(//gi,'\n'); s=s.replace(/\x00R(\d+)\x00/g,(_,i)=>rawPreStash[+i]); + // ── Glued-bold-heading lift (issue #1446) ──────────────────────────────── + // LLMs in thinking/reasoning mode frequently emit a "section header" glued + // to the end of the previous paragraph with no whitespace, like: + // + // Para 1 text.**Heading to Para 2** + // + // Para 2 text.**Heading to Para 3** + // + // CommonMark renders that correctly as paragraph-end inline bold, but the + // visual effect is a run-on label rather than a section break. Lift the + // glued bold into its own paragraph when it follows a sentence terminator + // and is followed by a blank line. + // + // Constraints (avoid false positives): + // - Trigger only on a sentence terminator (.!?) IMMEDIATELY before `**` + // (no space) — that pattern is almost always a glued heading, not + // intentional emphasis. + // - Inner text length ≤ 80 chars — long bold runs are usually emphasis + // prose, not headings. + // - Trailing `\n\n` required — preserves mid-paragraph emphasis like + // "this is **important**." untouched. + // - Inner text must not contain newlines or `*` (single-line bold only). + // - Runs after fenced code, math, and raw
 are stashed, so code
+  //     content is protected (see pipeline notes).
+  s=s.replace(/([.!?])\*\*([^*\n]{1,80})\*\*\n\n/g,'$1\n\n**$2**\n\n');
   // Inline backtick spans: restore  tags produced in the stash callback above.
   // Must happen BEFORE bold/italic so **`code`** → code.
   s=s.replace(/\x00F(\d+)\x00/g,(_,i)=>fence_stash[+i]);
@@ -2487,7 +2512,7 @@ function _ensureAppDialogBindings(){
       return;
     }
     if(e.key==='Enter'){
-      if(e.isComposing) return;
+      if(window._isImeEnter&&window._isImeEnter(e)) return;
       const target=e.target;
       const isTextarea=target&&target.tagName==='TEXTAREA';
       if(!isTextarea){
@@ -4104,7 +4129,7 @@ function editMessage(btn) {
   bar.querySelector('.msg-edit-cancel').onclick = () => cancelEdit(row, originalText, body);
 
   ta.addEventListener('keydown', e => {
-    if(e.key==='Enter' && !e.shiftKey) { if(e.isComposing) return; e.preventDefault(); bar.querySelector('.msg-edit-send').click(); }
+    if(e.key==='Enter' && !e.shiftKey) { if(window._isImeEnter&&window._isImeEnter(e)) return; e.preventDefault(); bar.querySelector('.msg-edit-send').click(); }
     if(e.key==='Escape') { e.preventDefault(); cancelEdit(row, originalText, body); }
   });
 }
@@ -5006,7 +5031,7 @@ function _renderTreeItems(container, entries, depth){
       };
       inp.onkeydown=(e2)=>{
         if(e2.key==='Enter'){
-          if(e2.isComposing){return;}
+          if(window._isImeEnter&&window._isImeEnter(e2)){return;}
           e2.preventDefault();
           finish(true);
         }
diff --git a/tests/test_ime_composition.py b/tests/test_ime_composition.py
index bf57f492..c30660ed 100644
--- a/tests/test_ime_composition.py
+++ b/tests/test_ime_composition.py
@@ -9,16 +9,20 @@ SESSIONS_JS = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
 
 
 def _ime_guarded_enter_pattern(event_var_pattern, require_no_shift=False):
-    """Accept both the original `e.isComposing` guard AND the broader
-    `_isImeEnter(e)` helper introduced in PR #1441 (which folds in
-    `keyCode===229` and a manual `_imeComposing` flag for Safari).
+    """Accept the IME guard in any of three shapes that have been used in the codebase:
+
+    1. Original `e.isComposing` (pre-#1441)
+    2. Module-local `_isImeEnter(e)` (PR #1441 — chat composer in boot.js)
+    3. Window-exposed `window._isImeEnter(e)` (issue #1443 — promoted to ui.js,
+       sessions.js so all 6 Enter-input sites use the same Safari-aware guard).
     """
     no_shift = rf"\s*&&\s*!\s*{event_var_pattern}\.shiftKey" if require_no_shift else ""
-    # Either: if(e.isComposing) ...  OR  if(_isImeEnter(e)) ...
+    # Either: if(e.isComposing) ... | if(_isImeEnter(e)) ... | if(window._isImeEnter&&window._isImeEnter(e)) ...
     guard = (
         rf"if\s*\(\s*"
         rf"(?:{event_var_pattern}\.isComposing"
-        rf"|_isImeEnter\(\s*{event_var_pattern}\s*\))"
+        rf"|_isImeEnter\(\s*{event_var_pattern}\s*\)"
+        rf"|window\._isImeEnter\s*&&\s*window\._isImeEnter\s*\(\s*{event_var_pattern}\s*\))"
         rf"\s*\)\s*"
     )
     return (
diff --git a/tests/test_issue1443_ime_helper_promotion.py b/tests/test_issue1443_ime_helper_promotion.py
new file mode 100644
index 00000000..4538f170
--- /dev/null
+++ b/tests/test_issue1443_ime_helper_promotion.py
@@ -0,0 +1,183 @@
+"""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())` 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'){  ...
+    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)."
+    )
diff --git a/tests/test_issue1446_glued_heading_lift.py b/tests/test_issue1446_glued_heading_lift.py
new file mode 100644
index 00000000..5857a5b8
--- /dev/null
+++ b/tests/test_issue1446_glued_heading_lift.py
@@ -0,0 +1,284 @@
+"""Regression tests for issue #1446 — glued-bold-heading lift in renderMd().
+
+LLMs in thinking/reasoning mode frequently emit content shaped like:
+
+    Paragraph 1 text text.**Heading to Paragraph 2**
+
+    Paragraph 2 text text.**Heading to Paragraph 3**
+
+    Paragraph 3 text...
+
+The renderer correctly produces (per CommonMark):
+
+    

Paragraph 1 text text.Heading to Paragraph 2

+

Paragraph 2 text text.Heading to Paragraph 3

+ +But the visual effect is a "trailing emphasis on the body text" rather than a +section header for what follows. Cygnus reported this in Discord (May 1 2026, +relayed by @AvidFuturist). + +Fix: pre-pass in `renderMd()` (and the Python mirror) lifts the glued bold into +its own paragraph when it sits at the end of a paragraph, follows a sentence +terminator (`.!?`), is reasonably short (≤80 chars), and is followed by a blank +line. Mid-paragraph emphasis like "this is **important** to know." is preserved. + +Behavioral tests below split into two sections: + + - Python mirror tests use ``render_md`` from ``test_sprint16`` and verify + the lift logic in cases where the mirror is faithful to the JS. + - Node-driver tests at the bottom run against the ACTUAL ``static/ui.js`` + via ``node`` and pin the cases that depend on the JS-specific stash + structure (fenced code, inline backticks) — these would false-fail + against the simpler Python mirror. +""" + +from __future__ import annotations + +from pathlib import Path +import re +import shutil +import subprocess + +import pytest + +from tests.test_sprint16 import render_md + + +REPO = Path(__file__).resolve().parent.parent +UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8") +UI_JS_PATH = REPO / "static" / "ui.js" +NODE = shutil.which("node") + + +# ── Behavior tests via the Python mirror ───────────────────────────────────── + + +def test_glued_bold_after_period_lifts_to_own_paragraph(): + """Sentence-glued **Heading** with period before it must be lifted to its own paragraph.""" + src = "Para text.**Bold heading**\n\nNext para." + out = render_md(src) + assert "

Para text.

" in out, f"Period sentence not isolated: {out!r}" + assert "

Bold heading

" in out, ( + f"Lifted bold not in its own paragraph: {out!r}" + ) + + +def test_glued_bold_after_question_mark_lifts(): + """Glued-bold after `?` should also lift — common in LLM reasoning mode.""" + src = "Why does this happen?**The answer**\n\nNext para." + out = render_md(src) + assert "

Why does this happen?

" in out, out + assert "

The answer

" in out, out + + +def test_glued_bold_after_exclamation_lifts(): + """Glued-bold after `!` should also lift — emphatic transition.""" + src = "Found it!**Section title**\n\nMore text." + out = render_md(src) + assert "

Found it!

" in out, out + assert "

Section title

" in out, out + + +# ── Preserve-emphasis cases (no false positives) ───────────────────────────── + + +def test_mid_paragraph_bold_unchanged(): + """Bold mid-sentence with no period before and no `\\n\\n` after must NOT be lifted.""" + src = "This is **important** to know." + out = render_md(src) + assert "

This is important to know.

" in out, out + + +def test_trailing_bold_without_period_unchanged(): + """Bold at end of paragraph WITHOUT a sentence-terminator before it must stay inline.""" + src = "Some text **emphasis** here." + out = render_md(src) + assert "emphasis" in out + assert "

emphasis

" not in out + + +def test_trailing_bold_with_period_after_bold_unchanged(): + """`text **important**.\\n\\n` (period AFTER bold) must NOT trigger the lift — + the regex requires period IMMEDIATELY before the `**`.""" + src = "This is **important**.\n\nNext." + out = render_md(src) + assert "

This is important.

" in out, out + assert "

important

" not in out + + +def test_glued_bold_without_blank_line_unchanged(): + """`text.**Bold**\\nMore text` (single newline, no blank line) must NOT be lifted — + the regex requires `\\n\\n` after.""" + src = "Para.**Bold**\nMore text on next line." + out = render_md(src) + assert "Bold" in out + assert "

Bold

" not in out + + +def test_long_bold_phrase_not_lifted(): + """Bold runs longer than 80 chars are likely emphasis prose, not headings — don't lift.""" + long_bold = "x" * 100 + src = f"Para.**{long_bold}**\n\nNext." + out = render_md(src) + assert f"{long_bold}" in out + assert f"

{long_bold}

" not in out + + +def test_intentional_block_final_bold_with_no_glue_unchanged(): + """`text **bold**\\n\\n` (space before `**`, no glued period) must NOT be lifted.""" + src = "Para text **bold**\n\nNext." + out = render_md(src) + assert "

Para text bold

" in out, out + + +# ── Multi-occurrence + paragraph chain ─────────────────────────────────────── + + +def test_chain_of_glued_headings_all_lifted(): + """Chained glued-heading paragraphs should all lift — common LLM thinking-mode shape.""" + src = ( + "First text.**Heading A**\n\n" + "Second text.**Heading B**\n\n" + "Third text.\n" + ) + out = render_md(src) + assert "

First text.

" in out, out + assert "

Heading A

" in out, out + assert "

Second text.

" in out, out + assert "

Heading B

" in out, out + assert "

Third text.

" in out, out + + +# ── Source-level structural check on ui.js ─────────────────────────────────── + + +def test_lift_pass_present_in_ui_js_at_correct_position(): + """The lift regex must be present in ui.js, between rawPreStash restore and fence_stash restore. + + This pins the position so a future cleanup can't accidentally move the lift + to a place where it would corrupt fenced code blocks (which are stashed as + \\x00P / \\x00F tokens at this point and don't match the lift regex). + """ + lift_idx = UI_JS.find(r'(/([.!?])\*\*([^*\n]{1,80})\*\*\n\n/g') + assert lift_idx > 0, "Glued-bold-heading lift regex not found in static/ui.js" + raw_pre_restore = UI_JS.find("rawPreStash[+i]") + fence_restore = UI_JS.find("fence_stash[+i]") + assert raw_pre_restore > 0 and fence_restore > 0, "stash restore landmarks missing" + assert raw_pre_restore < lift_idx < fence_restore, ( + "Glued-bold lift must sit between rawPreStash restore and fence_stash restore " + "so fenced code is protected. Current ordering broken." + ) + + +def test_lift_regex_is_single_line_only(): + """The lift's inner-bold-text class must exclude `*` and `\\n` so multi-line/nested bold + cannot be matched.""" + assert re.search( + r"\[\^\*\\n\]\{1,80\}", + UI_JS, + ), "Lift regex inner class must be `[^*\\n]{1,80}` (single-line, ≤80 chars)" + + +# ── Node-driver tests (run against the actual JS) ──────────────────────────── + + +_DRIVER_SRC = r""" +const fs = require('fs'); +const src = fs.readFileSync(process.argv[2], 'utf8'); +global.window = {}; +global.document = { createElement: () => ({ innerHTML: '', textContent: '' }) }; +const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ( + {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +const _IMAGE_EXTS=/\.(png|jpg|jpeg|gif|webp|bmp|ico|avif)$/i; +const _SVG_EXTS=/\.svg$/i; +const _AUDIO_EXTS=/\.(mp3|ogg|wav|m4a|aac|flac|wma|opus|webm)$/i; +const _VIDEO_EXTS=/\.(mp4|webm|mkv|mov|avi|ogv|m4v)$/i; + +function extractFunc(name) { + const re = new RegExp('function\\s+' + name + '\\s*\\('); + const start = src.search(re); + if (start < 0) throw new Error(name + ' not found'); + let i = src.indexOf('{', start); + let depth = 1; i++; + while (depth > 0 && i < src.length) { + if (src[i] === '{') depth++; + else if (src[i] === '}') depth--; + i++; + } + return src.slice(start, i); +} +eval(extractFunc('renderMd')); + +let buf = ''; +process.stdin.on('data', c => { buf += c; }); +process.stdin.on('end', () => { process.stdout.write(renderMd(buf)); }); +""" + + +@pytest.fixture(scope="module") +def driver_path(tmp_path_factory): + if NODE is None: + pytest.skip("node not on PATH") + p = tmp_path_factory.mktemp("renderer1446_driver") / "driver.js" + p.write_text(_DRIVER_SRC, encoding="utf-8") + return str(p) + + +def _render(driver_path: str, markdown: str) -> str: + result = subprocess.run( + [NODE, driver_path, str(UI_JS_PATH)], + input=markdown, + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode != 0: + raise RuntimeError(f"node driver failed: {result.stderr}") + return result.stdout + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_real_renderer_lifts_glued_heading(driver_path): + """Drive the real ui.js renderMd and confirm the lift fires in the basic case.""" + out = _render(driver_path, "Para text.**Bold heading**\n\nNext para.\n") + assert "

Para text.

" in out, out + assert "

Bold heading

" in out, out + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_real_renderer_protects_fenced_code(driver_path): + """Glued pattern inside fenced code MUST stay literal — fence_stash is active when the lift runs.""" + src = "```\nsource.**inside-code**\n\nstill-in-code\n```\n" + out = _render(driver_path, src) + assert "inside-code" not in out, out + assert "**inside-code**" in out, out + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_real_renderer_protects_inline_code(driver_path): + """Glued pattern inside inline backticks must stay literal.""" + out = _render(driver_path, "Some `code.**glued**` text.\n") + assert "code.**glued**" in out, out + assert "glued" not in out, out + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_real_renderer_preserves_mid_paragraph_emphasis(driver_path): + """Mid-paragraph emphasis must stay inline in the real renderer.""" + out = _render(driver_path, "This is **important** to know.\n") + assert "

This is important to know.

" in out, out + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_real_renderer_chain_of_glued_headings(driver_path): + """Chain of glued headings — verifies the regex's `g` flag fires multiple times.""" + src = ( + "First text.**Heading A**\n\n" + "Second text.**Heading B**\n\n" + "Third text.\n" + ) + out = _render(driver_path, src) + assert "

First text.

" in out, out + assert "

Heading A

" in out, out + assert "

Second text.

" in out, out + assert "

Heading B

" in out, out diff --git a/tests/test_issue1447_heading_hierarchy.py b/tests/test_issue1447_heading_hierarchy.py new file mode 100644 index 00000000..0639e4a7 --- /dev/null +++ b/tests/test_issue1447_heading_hierarchy.py @@ -0,0 +1,209 @@ +"""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 diff --git a/tests/test_login_locale_parity.py b/tests/test_login_locale_parity.py new file mode 100644 index 00000000..42aa88ff --- /dev/null +++ b/tests/test_login_locale_parity.py @@ -0,0 +1,330 @@ +"""Regression tests for the server-side `_LOGIN_LOCALE` parity with `static/i18n.js`. + +Issue #1442: when v0.50.264 added `ja` as the 8th built-in locale, Opus pre-release +advisor surfaced that `_LOGIN_LOCALE` (in `api/routes.py`) only contained +`en/es/de/ru/zh/zh-Hant`. `ja`, `pt`, and `ko` users would see the English login page +even after their UI language was set, because `_resolve_login_locale_key()` falls +through every check and returns "en" when the locale is missing. + +These tests pin two invariants going forward: + +1. Every locale key registered in `static/i18n.js` LOCALES (top-level) must also + exist as a key in `_LOGIN_LOCALE` — so adding a new locale to i18n.js without + updating `_LOGIN_LOCALE` is caught at test time. + +2. Every entry in `_LOGIN_LOCALE` must carry the full required string set + (`lang/title/subtitle/placeholder/btn/invalid_pw/conn_failed`) and every value + must be a non-empty string. + +The companion follow-up — closing the i18n.js login-flow English-leak gaps for +`ko` (10 keys) and `es` (3 keys), and adding the 3 missing `pt` keys — is verified +in `test_login_flow_translation_parity` below: every locale must have non-English +values for the user-facing login/sign-out/password keys. + +See issue #1442. +""" + +from __future__ import annotations + +from pathlib import Path +import importlib +import re +import sys + +import pytest + + +REPO = Path(__file__).resolve().parent.parent +I18N_PATH = REPO / "static" / "i18n.js" + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + + +def _load_login_locale() -> dict: + """Import `_LOGIN_LOCALE` from `api.routes` without booting the HTTP server.""" + sys.path.insert(0, str(REPO)) + try: + routes = importlib.import_module("api.routes") + finally: + # Don't pop — leaving sys.path[0] is fine for the rest of the suite. + pass + return routes._LOGIN_LOCALE + + +def _i18n_top_level_locale_keys() -> list[str]: + """Return the ordered list of top-level locale keys defined in static/i18n.js LOCALES.""" + src = I18N_PATH.read_text(encoding="utf-8") + # Find `const LOCALES = {` + m = re.search(r"const\s+LOCALES\s*=\s*\{", src) + assert m, "LOCALES object not found in static/i18n.js" + body_start = m.end() + # Walk braces to find matching close, respecting strings/comments + depth = 1 + i = body_start + n = len(src) + while i < n and depth > 0: + ch = src[i] + if ch == "/" and i + 1 < n and src[i + 1] == "/": + nl = src.find("\n", i) + i = n if nl < 0 else nl + 1 + continue + if ch == "/" and i + 1 < n and src[i + 1] == "*": + end = src.find("*/", i + 2) + i = n if end < 0 else end + 2 + continue + if ch in ("'", '"'): + q = ch + i += 1 + while i < n and src[i] != q: + i += 2 if src[i] == "\\" else 1 + i += 1 + continue + if ch == "`": + i += 1 + while i < n and src[i] != "`": + i += 2 if src[i] == "\\" else 1 + i += 1 + continue + if ch == "{": + depth += 1 + i += 1 + continue + if ch == "}": + depth -= 1 + if depth == 0: + body_end = i + break + i += 1 + continue + i += 1 + else: + raise AssertionError("LOCALES object never closed in static/i18n.js") + + body = src[body_start:body_end] + + # Top-level locale keys are at 2-space indent: either `xx: {` or `'xx-Hant': {`. + # Use brace-tracking so we only pick up *top-level* keys, not nested ones. + keys: list[str] = [] + j = 0 + sub_depth = 0 + blen = len(body) + while j < blen: + ch = body[j] + if ch == "/" and j + 1 < blen and body[j + 1] == "/": + nl = body.find("\n", j) + j = blen if nl < 0 else nl + 1 + continue + if ch == "/" and j + 1 < blen and body[j + 1] == "*": + end = body.find("*/", j + 2) + j = blen if end < 0 else end + 2 + continue + if ch in ("'", '"'): + q = ch + j += 1 + while j < blen and body[j] != q: + j += 2 if body[j] == "\\" else 1 + j += 1 + continue + if ch == "`": + j += 1 + while j < blen and body[j] != "`": + j += 2 if body[j] == "\\" else 1 + j += 1 + continue + if ch == "{": + sub_depth += 1 + j += 1 + continue + if ch == "}": + sub_depth -= 1 + j += 1 + continue + # Detect top-level key only when sub_depth is 0 and we're at the start + # of a fresh line (after a newline) at column 2. + if sub_depth == 0 and ch == "\n": + # Look at the next characters: ` KEY: {` where KEY is identifier or 'identifier-with-dash' + tail = body[j + 1 : j + 200] + mk = re.match( + r" (?:'(?P[A-Za-z][A-Za-z0-9_-]*)'|(?P[A-Za-z][A-Za-z0-9_]*))\s*:\s*\{", + tail, + ) + if mk: + keys.append(mk.group("q") or mk.group("u")) + j += 1 + # Deduplicate while preserving order (LOCALES is a single object so no dups expected, + # but be defensive in case the file ever picks them up). + seen = set() + ordered_unique = [] + for k in keys: + if k not in seen: + seen.add(k) + ordered_unique.append(k) + return ordered_unique + + +def _i18n_locale_block(loc: str) -> str: + """Return the body of a specific top-level locale block in i18n.js.""" + src = I18N_PATH.read_text(encoding="utf-8") + if "-" in loc: + head = re.compile(rf"^ '{re.escape(loc)}':\s*\{{", re.M) + else: + head = re.compile(rf"^ {re.escape(loc)}:\s*\{{", re.M) + hm = head.search(src) + assert hm, f"locale {loc!r} not found in i18n.js" + body_start = hm.end() + depth = 1 + i = body_start + n = len(src) + while i < n and depth > 0: + ch = src[i] + if ch == "/" and i + 1 < n and src[i + 1] == "/": + nl = src.find("\n", i) + i = n if nl < 0 else nl + 1 + continue + if ch == "/" and i + 1 < n and src[i + 1] == "*": + end = src.find("*/", i + 2) + i = n if end < 0 else end + 2 + continue + if ch in ("'", '"'): + q = ch + i += 1 + while i < n and src[i] != q: + i += 2 if src[i] == "\\" else 1 + i += 1 + continue + if ch == "`": + i += 1 + while i < n and src[i] != "`": + i += 2 if src[i] == "\\" else 1 + i += 1 + continue + if ch == "{": + depth += 1 + i += 1 + continue + if ch == "}": + depth -= 1 + if depth == 0: + return src[body_start:i] + i += 1 + continue + i += 1 + raise AssertionError(f"locale {loc!r} block never closed") + + +# Required sub-keys for every _LOGIN_LOCALE entry. +REQUIRED_LOGIN_KEYS = ( + "lang", + "title", + "subtitle", + "placeholder", + "btn", + "invalid_pw", + "conn_failed", +) + +# Login-flow user-facing keys that must be translated (non-English) in every locale. +# Adding a new locale to i18n.js without these translated will leak English to the +# user during the very first run / login experience. +LOGIN_FLOW_TRANSLATED_KEYS = ( + "login_title", + "login_subtitle", + "login_placeholder", + "login_btn", + "login_invalid_pw", + "login_conn_failed", + "sign_out", + "sign_out_failed", + "password_placeholder", + "settings_saved_pw", + "settings_saved_pw_updated", + "auth_disabled", + "disable_auth_confirm_title", +) + + +# ── Tests ───────────────────────────────────────────────────────────────────── + + +def test_every_i18n_locale_has_login_locale_entry(): + """Every locale in static/i18n.js LOCALES must also exist as a key in _LOGIN_LOCALE.""" + i18n_keys = _i18n_top_level_locale_keys() + login = _load_login_locale() + missing = [k for k in i18n_keys if k not in login] + assert not missing, ( + f"_LOGIN_LOCALE is missing entries for these i18n locales: {missing}. " + f"Add the matching entry in api/routes.py to keep the login page localized " + f"for every supported locale (issue #1442)." + ) + + +def test_login_locale_count_matches_or_exceeds_floor(): + """_LOGIN_LOCALE must contain at least the 9 launch locales (en, es, de, ru, zh, zh-Hant, ja, pt, ko).""" + login = _load_login_locale() + assert len(login) >= 9, f"_LOGIN_LOCALE shrank: only {len(login)} entries" + for k in ("en", "es", "de", "ru", "zh", "zh-Hant", "ja", "pt", "ko"): + assert k in login, f"_LOGIN_LOCALE missing core locale {k!r}" + + +@pytest.mark.parametrize("loc_key", ["en", "es", "de", "ru", "zh", "zh-Hant", "ja", "pt", "ko"]) +def test_login_locale_entry_well_formed(loc_key: str): + """Each _LOGIN_LOCALE entry must have all required sub-keys and non-empty string values.""" + login = _load_login_locale() + entry = login[loc_key] + assert set(entry.keys()) == set(REQUIRED_LOGIN_KEYS), ( + f"_LOGIN_LOCALE[{loc_key!r}] keys mismatch. " + f"Expected {set(REQUIRED_LOGIN_KEYS)}, got {set(entry.keys())}." + ) + for k, v in entry.items(): + assert isinstance(v, str) and v, f"_LOGIN_LOCALE[{loc_key!r}][{k!r}] is empty/non-str: {v!r}" + + +def test_login_locale_resolver_handles_new_locales(): + """_resolve_login_locale_key() must map ja/pt/ko (and common BCP-47 variants) to their entries.""" + sys.path.insert(0, str(REPO)) + from api.routes import _resolve_login_locale_key + + assert _resolve_login_locale_key("ja") == "ja" + assert _resolve_login_locale_key("ja-JP") == "ja" + assert _resolve_login_locale_key("ja_JP") == "ja" + assert _resolve_login_locale_key("pt") == "pt" + assert _resolve_login_locale_key("pt-BR") == "pt" + assert _resolve_login_locale_key("pt-PT") == "pt" + assert _resolve_login_locale_key("ko") == "ko" + assert _resolve_login_locale_key("ko-KR") == "ko" + # Unknown locale still falls back to en. + assert _resolve_login_locale_key("fr") == "en" + assert _resolve_login_locale_key("xx-YY") == "en" + + +def _value_of(seg: str, key: str) -> str | None: + m = re.search(rf"\b{re.escape(key)}:\s*'((?:\\.|[^'\\])*)'", seg) + if m: + return m.group(1) + m = re.search(rf'\b{re.escape(key)}:\s*"((?:\\.|[^"\\])*)"', seg) + if m: + return m.group(1) + return None + + +@pytest.mark.parametrize("loc_key", ["es", "de", "ru", "zh", "zh-Hant", "ja", "pt", "ko"]) +def test_login_flow_keys_are_translated(loc_key: str): + """Login/sign-out/password keys in static/i18n.js must NOT equal the English value. + + This guards the `ko` 10-key, `es` 3-key, and `pt` 3-key gaps closed in this PR. + Adding a new locale that copies English values for these keys leaks English to + the user during their very first interaction with the app. + """ + en_seg = _i18n_locale_block("en") + target_seg = _i18n_locale_block(loc_key) + leaks = [] + for k in LOGIN_FLOW_TRANSLATED_KEYS: + en_val = _value_of(en_seg, k) + loc_val = _value_of(target_seg, k) + if en_val and loc_val is not None and loc_val == en_val: + leaks.append(f"{k}={loc_val!r}") + assert not leaks, ( + f"Locale {loc_key!r} leaks English for login-flow keys: {leaks}. " + f"Translate these in static/i18n.js (issue #1442)." + ) diff --git a/tests/test_sprint16.py b/tests/test_sprint16.py index 103ae069..e273e7e8 100644 --- a/tests/test_sprint16.py +++ b/tests/test_sprint16.py @@ -69,6 +69,10 @@ def render_md(raw): s = re.sub(r"([\s\S]*?)", lambda m: "*" + m.group(1) + "*", s, flags=re.I) s = re.sub(r"([^<]*?)", lambda m: "`" + m.group(1) + "`", s, flags=re.I) s = re.sub(r"", "\n", s, flags=re.I) + # Glued-bold-heading lift (issue #1446) — must mirror static/ui.js position: + # after raw
 restore, before fence_stash restore. Lifts a sentence-glued
+    # bold "stub heading" out into its own paragraph when followed by a blank line.
+    s = re.sub(r"([.!?])\*\*([^*\n]{1,80})\*\*\n\n", r"\1\n\n**\2**\n\n", s)
     s = re.sub(r"\x00F(\d+)\x00", lambda m: fence_stash[int(m.group(1))], s)
 
     # Fenced code blocks