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)
This commit is contained in:
nesquena-hermes
2026-05-02 04:04:23 +00:00
parent 0ed6103f1e
commit c73f2ff387
12 changed files with 1136 additions and 42 deletions
+30
View File
@@ -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",
},
}
+9
View File
@@ -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');
+17 -13
View File
@@ -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`,
+3 -3
View File
@@ -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);
}
+30 -18
View File
@@ -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;}
+28 -3
View File
@@ -1629,6 +1629,31 @@ function renderMd(raw){
s=s.replace(/<code>([^<]*?)<\/code>/gi,(_,t)=>'`'+t+'`');
s=s.replace(/<br\s*\/?>/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 <pre> 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 <code> tags produced in the stash callback above.
// Must happen BEFORE bold/italic so **`code`** → <strong><code>code</code></strong>.
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);
}
+9 -5
View File
@@ -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 (
@@ -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(<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)."
)
+284
View File
@@ -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):
<p>Paragraph 1 text text.<strong>Heading to Paragraph 2</strong></p>
<p>Paragraph 2 text text.<strong>Heading to Paragraph 3</strong></p>
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 "<p>Para text.</p>" in out, f"Period sentence not isolated: {out!r}"
assert "<p><strong>Bold heading</strong></p>" 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 "<p>Why does this happen?</p>" in out, out
assert "<p><strong>The answer</strong></p>" 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 "<p>Found it!</p>" in out, out
assert "<p><strong>Section title</strong></p>" 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 "<p>This is <strong>important</strong> to know.</p>" 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 "<strong>emphasis</strong>" in out
assert "<p><strong>emphasis</strong></p>" 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 "<p>This is <strong>important</strong>.</p>" in out, out
assert "<p><strong>important</strong></p>" 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 "<strong>Bold</strong>" in out
assert "<p><strong>Bold</strong></p>" 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"<strong>{long_bold}</strong>" in out
assert f"<p><strong>{long_bold}</strong></p>" 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 "<p>Para text <strong>bold</strong></p>" 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 "<p>First text.</p>" in out, out
assert "<p><strong>Heading A</strong></p>" in out, out
assert "<p>Second text.</p>" in out, out
assert "<p><strong>Heading B</strong></p>" in out, out
assert "<p>Third text.</p>" 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 => (
{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 "<p>Para text.</p>" in out, out
assert "<p><strong>Bold heading</strong></p>" 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 "<strong>inside-code</strong>" 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>code.**glued**</code>" in out, out
assert "<strong>glued</strong>" 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 "<p>This is <strong>important</strong> to know.</p>" 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 "<p>First text.</p>" in out, out
assert "<p><strong>Heading A</strong></p>" in out, out
assert "<p>Second text.</p>" in out, out
assert "<p><strong>Heading B</strong></p>" in out, out
+209
View File
@@ -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 `<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
+330
View File
@@ -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<q>[A-Za-z][A-Za-z0-9_-]*)'|(?P<u>[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)."
)
+4
View File
@@ -69,6 +69,10 @@ def render_md(raw):
s = re.sub(r"<i>([\s\S]*?)</i>", lambda m: "*" + m.group(1) + "*", s, flags=re.I)
s = re.sub(r"<code>([^<]*?)</code>", lambda m: "`" + m.group(1) + "`", s, flags=re.I)
s = re.sub(r"<br\s*/?>", "\n", s, flags=re.I)
# Glued-bold-heading lift (issue #1446) — must mirror static/ui.js position:
# after raw <pre> 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