Composer footer rendered two near-identical mic icons whose tooltips both
said "Voice input" — push-to-talk dictation and hands-free voice mode were
visually indistinguishable. Researched how ChatGPT/Claude/Gemini solve the
same problem and adopt the industry convention.
Changes:
- btnVoiceMode now uses Lucide audio-lines (6 vertical bars), the
universal voice-conversation glyph. Also registered in LI_PATHS.
- Distinct localized tooltips: voice_dictate ("Dictate") and
voice_mode_toggle ("Voice mode"), with active-state flips
(voice_dictate_active "Stop dictation", voice_mode_toggle_active
"Exit voice mode"). Legacy voice_toggle key removed (it resolved to
"Voice input" in every locale and caused the duplicate-tooltip bug).
- Voice mode is opt-in via Settings -> Preferences ->
"Hands-free voice mode button" (default off). Dictation mic stays
visible by default, unchanged. localStorage-backed; panels.js onchange
calls window._applyVoiceModePref() so the button appears/disappears
immediately without reload.
- 17 regression tests pin: distinct titles, audio-lines glyph, all 4
new keys in all 9 locales, removal of stale voice_toggle, English
labels match convention, pref gating (no unconditional display=''
left in boot.js), Settings checkbox + i18n, panels.js wiring,
active-state tooltip flips.
Browser-verified on port 8789: default state shows 1 mic; enabling
the pref makes the audio-waveform button appear live; tooltips read
"Dictate" and "Voice mode" distinctly.
Closes#1488
The JavaScript _normalizeConfiguredModelKey function had the same bug as the
Python _norm_model_id function that was fixed in commit d6164cd. It used
substring(indexOf(':')+1) which only removes the first colon-separated segment,
leaving provider names in the normalized model ID.
For example, '@custom:jingdong:GLM-5' became 'jingdong:glm.5' instead of 'glm.5'.
This caused duplicate Primary badges to appear in the model dropdown when using
custom providers with @provider:model ID format.
Changes:
- Replace substring(indexOf(':')+1) with split(':').pop() to strip all colon prefixes
- Add provider name to badge label for clarity (e.g., 'Primary (jingdong)')
When the webui auth session expires (e.g., after a server restart),
api() returns undefined after redirecting to /login. Previously,
loadSession() and _ensureMessagesLoaded() would dereference the
undefined response and throw, surfacing a confusing 'Failed to load
session' toast while the browser was already navigating away.
Add guards after api() calls that may trigger 401 redirects:
- loadSession(): bail early if data is undefined
- _ensureMessagesLoaded(): return silently if data is missing
- _loadOlderMessages(): return silently if data is missing
This prevents the stuck loading state and unnecessary error toasts
when the user is already being redirected to re-authenticate.
Fixes#1391 (reported as 'Failed to load session' after restart)
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)
Opus advisor caught a recoverable footgun in PR #1441's manual flag: if
focus is lost mid-composition (window blur or older Safari WebKit IME
quirk), compositionend may never fire and _imeComposing stays true
until the next full composition cycle. Result: Enter-to-send is
silently broken until page reload — an unrecoverable stuck state for
something that's supposed to be transient.
Add a blur listener that also resets the flag. Cheap belt-and-suspenders
against the stuck state. Adds 1 regression test pinning the listener.
(other Opus findings logged in /tmp/stage-264-brief.md as follow-up
issues: _LOGIN_LOCALE parity for ja/pt/ko, promote _isImeEnter to the
6 other Safari-affected Enter guards in sessions.js + ui.js)
The markdown fence regex /```([\s\S]*?)```/g had no line anchoring. A literal
triple backtick inside code block content (e.g. a regex with ``` in a lookbehind,
or a script that documents fences) terminated the outer fence at the wrong place.
The leaked tail then went through bold/italic/inline-code passes, eating `*`
characters as italic markers and emitting literal </strong> tags into the
rendered output.
CommonMark §4.5 requires that an opening code fence be the first non-whitespace
content of a line (up to 3 spaces of indent allowed) and that the closing fence
also start a line. This patch updates 3 sites + the Python mirror to use that
invariant:
static/ui.js:1559 renderMd() fenced-block stash (assistant messages)
static/ui.js:66 _renderUserFencedBlocks() (user messages)
static/ui.js:2599 _stripForTTS() (TTS speech pre-strip)
tests/test_sprint16.py Python mirror
Pattern: (^|\n)[ ]{0,3}```(?:([\s\S]*?)\n)?[ ]{0,3}```(?=\n|$)
The non-capturing (?:...\n)? group keeps empty fences (```\n```) working;
without it, a body+\n is required and the closing fence on the very next line
no longer matches. The lead group (^|\n) is prefixed back to the stash token
so paragraphs above don't bleed into the <pre> block.
20 regression tests in tests/test_issue1438_fence_anchoring.py cover:
- Cygnus's exact repro from Discord (May 1 2026)
- Inline ``` mid-paragraph (must not open fence)
- Partial/streaming fence with no close (must not eat content)
- Empty fences with and without language tag
- 3-space indented fences (allowed) vs 4-space (not a fence)
- Multiple adjacent blocks
- Bold/italic/inline-code surviving after a fence
- Source-level guards on all 3 patched sites + lead-prefix invariant
Empirical browser verification (live JS, on bug repro):
Before fix: </code></pre>[^\n]<em>|%%[ \t]</em>... ← truncated, italic leak
After fix: <pre><code>...```[^\n]*|%%...</code></pre> ← intact, regex preserved
Tests: 3678 passed (+20 from new test file, was 3658), 0 failures.
Reported-By: Cygnus (Discord)
Relayed-By: @AvidFuturist
Closes#1438
Adds a ja locale entry (828 keys) under static/i18n.js LOCALES,
inserted between en and ru. All existing keys translated to natural
concise Japanese suitable for UI labels, with placeholders ({0}, etc.)
and template literals preserved verbatim.
- _lang: 'ja', _label: '日本語', _speech: 'ja-JP'
- 828 keys (matches en, including the documented duplicate keys
whose JS last-wins semantics are preserved)
- syntax verified with `node -c static/i18n.js`
Tested live on a self-hosted instance; Settings → Language → 日本語
selects the new locale and switches the UI text.
East Asian IMEs (Japanese/Chinese/Korean) use Enter to commit composition.
The existing isComposing guard misses Safari, where the committing keydown
fires after compositionend with isComposing=false. Also track composition
manually and check keyCode===229 for broader coverage.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fix two-layer bug where `/api/session` returned `context_length=0` for
sessions that pre-date #1318, then the frontend silently fell back to
cumulative `input_tokens` and the 128K JS default, producing nonsense
indicators like "100" capped from "890% used (context exceeded), 1.2M
/ 131.1k tokens used".
Empirical impact: 23 of 75 sessions on dev server rendered >100% before
this fix. #1356 fixed the same symptom on the live SSE path but missed
the GET /api/session load path that older sessions go through.
Two-layer fix:
1. Backend (api/routes.py:1295-1313) — resolve context_length via
agent.model_metadata.get_model_context_length() when the persisted
value is 0. Mirrors api/streaming.py:2333-2342.
2. Frontend (static/ui.js:1269) — drop the cumulative `input_tokens`
fallback. When last_prompt_tokens is missing, render "·" + "tokens
used" (existing !hasPromptTok branch) instead of computing a
percentage from the cumulative total.
10 regression tests in tests/test_issue1436_context_indicator_load_path.py
covering both layers + the empty-model edge case (avoids the 256K
default-for-unknown-model trap that get_model_context_length('') returns).
Verified live: claude-opus-4-7 session with input_tokens=5,226,479 now
renders "·" + "5.3M tokens used" instead of "100" + "3987% used".
Reported by @AvidFuturist.
Closes#1436.
Two unrelated UX bugs, both small surgical fixes with regression tests.
Issue #1432 — "+" button doesn't open new chat during streaming
================================================================
Reported by @Olyno: clicking "+" after sending a first message keeps
redirecting to the same chat instead of opening a new blank conversation,
making parallel chats impossible until the first response finishes.
Root cause:
static/boot.js:691 (and the Cmd/Ctrl+K branch at :844) had an empty-session
guard from #1171 that skipped newSession() when message_count===0:
if(S.session && (S.session.message_count||0)===0){
$('msg').focus(); closeMobileSidebar(); return;
}
But during the first user turn of a brand-new session, message_count is
still 0 server-side because the user message hasn't been merged into
s.messages yet. The guard treated that as "empty" and silently dropped
the click, blocking parallel chats for the entire stream duration.
Fix:
Tighten the predicate to also exclude in-flight state:
if(S.session
&& (S.session.message_count||0)===0
&& !S.busy
&& !S.session.active_stream_id
&& !S.session.pending_user_message){
$('msg').focus(); closeMobileSidebar(); return;
}
Same predicate applied to the Cmd/Ctrl+K handler at :844. The in-flight
signal (active_stream_id || pending_user_message) is the same one
_restoreSettledSession() in messages.js:1081 already uses to decide
whether a session is "settled" — keeping both call sites aligned.
Verified end-to-end: with S.busy=true and pending_user_message set, the
old guard returned `block=true` (= the bug), the new guard returns
`block=false` (= fixed). With a truly empty session (no busy, no pending),
both old and new guards still block — preserving #1171 behavior.
Issue #1423 — Profile name field auto-capitalizes typed values
==============================================================
Self-reported (Mac app, May 1 2026): typing `hello` into the New Profile
"Name" field shows `Hello` after blur/autofill, contradicting the
"Lowercase letters, numbers, hyphens, underscores only" hint right next
to it. The form lowercases on submit so stored data is correct, but the
displayed value during typing is misleading.
Root cause:
static/panels.js:2532 had only autocomplete="off":
<input type="text" id="profileFormName"
placeholder="..." autocomplete="off" required>
Missing three attributes that actually prevent the misbehavior:
- autocapitalize="none" — mobile keyboards (iOS Safari, Android Chrome,
WKWebView in the Mac app) auto-capitalize the first letter without it
- autocorrect="off" — Safari runs autocorrect on blur, can rewrite hello→Hello
- spellcheck="false" — desktop browsers may run spellcheck on blur
Fix:
Add the three attributes to profileFormName. Also added to
profileFormBaseUrl since URLs are similarly bad targets for
autocapitalize/autocorrect. profileFormApiKey is type="password" and
already has correct browser behavior.
Verified end-to-end against the live DOM: openProfileCreate() →
getElementById('profileFormName').getAttribute(...) returns the new
attributes correctly, with required preserved.
Tests
-----
3648 passed, 2 skipped, 3 xpassed (was 3640 — added 8 new regression tests
in test_1432_newchat_and_1423_profile_input.py).
One pre-existing test had to be widened: tests/test_mobile_layout.py
test_new_conversation_closes_mobile_sidebar grabbed only the first 500
chars of the btnNewChat handler block to scan for closeMobileSidebar.
The new comment block pushed closeMobileSidebar past that window even
though both calls are still present. Bumped the window to 1500 chars
and the shortcut-block lines from 12 to 24 to match the multi-line guard.
Closes#1432Closes#1423
Reported by @Olyno (#1432, GitHub)
Three fixes from Opus advisor review of stage-261:
1. CRITICAL: dropdown-survives-resize bug. The composerToolsetsDropdown is a
DOM sibling of composerToolsetsWrap, not a child, so CSS hiding the wrap
does not cascade-hide an open dropdown. If a user opens the dropdown at
composer-footer >= 1100px and then opens the workspace panel (or resizes
the window), the dropdown would stay open without a visible anchor.
Fixed in three places (defense-in-depth):
- resize listener: closes dropdown when chip.offsetParent === null
- _positionToolsetsDropdown: closes if chip hidden (defense-in-depth)
- toggleToolsetsDropdown: early-returns if chip hidden (defense against
future #1431 redesign code that might invoke from elsewhere)
2. MEDIUM: display:flex changed to display:block to match sibling wraps
(.composer-profile-wrap, .composer-model-wrap, .composer-reasoning-wrap
all use the natural block display).
3. Added 3 new regression tests to pin all three guards.
Refs #1431, #1433.
Replaces PR #1433 unconditional JS display:none with a CSS @container query
that shows the chip only at composer-footer widths >= 1100px. JS now clears
inline style instead of setting display:none, so the CSS responsive cascade
is the single source of truth. Also removed inline style=\"display:none\" from
index.html so the CSS base rule provides the default-hidden state.
10 regression tests pin the base hide, wide-container show, narrow-container
hide (520px container query), mobile viewport hide (640px @media), JS does
not force display:none, JS clears inline style, /api/session/toolsets and
the dropdown machinery (toggleToolsetsDropdown, _populateToolsetsDropdown)
are preserved.
Refs #1431, #1433.
The session-toolsets restriction chip (#493) was making the composer
footer too cramped on narrower widths once it was sharing space with
model, reasoning effort, profile, and context-usage indicators.
Surgical fix: `_applyToolsetsChip()` now sets the wrap to display:none
unconditionally. Underlying state and the /api/session/toolsets endpoint
still work, so any cron job or scripted client that relies on
`enabled_toolsets` continues unaffected. To be revisited when the
footer layout is redesigned (#1430).
When the server is unreachable (VPN/Tailscale off), the login page now
polls /health every 3 seconds instead of failing silently. Once the
server becomes reachable, the page reloads automatically so the user
doesn't have to manually refresh.
The li() helper in static/icons.js logs console.warn and returns ''
when an icon name is not in LI_PATHS. Five icon names referenced by
static/*.js were never registered, so their host elements rendered as
empty 0-size buttons / containers despite display:flex.
Five missing icons added:
- 'volume-2' — TTS speaker on every assistant message
(ui.js:3376; regression from #499; surfaced after
#1411 fixed CSS specificity in v0.50.255)
- 'chevron-up' — queue pill chevron (ui.js:2178; the '▲' fallback
only fired when li was undefined, not when it
returned '')
- 'hash' — Insights 'Messages' stat card (panels.js:883)
- 'cpu' — Insights 'Tokens' stat card (panels.js:884)
- 'dollar-sign' — Insights 'Cost' stat card (panels.js:885)
The Insights icons are a fresh regression from #1405 (v0.50.255).
Adds tests/test_issue1413_li_path_coverage.py — three tests:
1. Walk every li('NAME', ...) call across static/*.js, assert NAME
is registered in LI_PATHS. Prevents the entire class of bug.
2. Pin the five icons added by this fix so removal gets a clear
error message.
3. Pin the warn+empty-string contract of li() so the diagnostic
story in the test docstring stays accurate.
Reported by @AvidFuturist via Telegram, 2026-05-01.
Fixes#1413
Opus pre-release advisor caught 4 issues in stage-255 (#1390 + #1405):
1. MUST-FIX: api/rollback.py path-traversal — _checkpoint_root() / ws_hash /
checkpoint did NOT normalize Path() / "../escape", so an authenticated
caller could read or restore from another allowlisted workspace via
../<other-ws-hash>/<sha>. New _validate_checkpoint_id() regex-guards
with ^[A-Za-z0-9_-][A-Za-z0-9_.-]{0,63}$ and rejects . and .. literals.
Both get_checkpoint_diff and restore_checkpoint validate.
2. SHOULD-FIX: redact_session_data perf cliff — the new api_redact_enabled
toggle in #1405 called uncached load_settings() per string, recursed
across messages[] and tool_calls[]. For a 50-message session: hundreds
of disk reads per /api/session response. Now read once at the top and
thread _enabled through via private kwarg.
3. SHOULD-FIX: voice-mode wrong-session TTS — the patched autoReadLastAssistant
fires globally; if the user navigated to a different session between
sending and stream completion, TTS would speak the wrong session\\s reply.
New _voiceModeThinkingSid closure captures S.session.session_id at
thinking-time; _speakResponse bails to _startListening() on mismatch.
4. NIT: rollback._inspect_checkpoint had bare Exception in the except tuple
alongside specific catches, swallowing everything. Now (TimeoutExpired,
OSError) only.
6 regression tests in test_v050255_opus_followups.py. Full suite: 3587 passed,
2 skipped, 3 xpassed.
Two unrelated UX/Settings bugs, both small surgical fixes with regression
tests.
Issue #1409 — TTS toggle has no effect
=======================================
Reported via Discord: ticking Settings → Voice → "Text-to-Speech for
responses" did nothing. The speaker icon never appeared on assistant
messages despite the checkbox saving to localStorage correctly.
Root cause (CSS specificity collision):
static/panels.js _applyTtsEnabled() set
btn.style.display = enabled ? '' : 'none'
on every .msg-tts-btn. The '' branch removes the inline override, after
which the .msg-tts-btn { display:none; } rule from style.css re-hides the
button. Both branches left the icon hidden, so the toggle has been
silently broken since #499 first shipped the TTS feature.
Fix (body-class toggle, Option B from the issue):
- panels.js: _applyTtsEnabled now toggles body.classList('tts-enabled')
- style.css: new compound selector
body.tts-enabled .msg-tts-btn { display:inline-flex; align-items:center; }
- default-hidden rule (.msg-tts-btn{display:none;}) preserved so the icon
stays hidden by default (CSS-only state)
- boot.js paths that already call _applyTtsEnabled(localStorage…) work
unchanged — the new function applies state at the body level instead of
inline-styling individual buttons, so the rule survives renderMd()
re-renders without re-querying every button
Verified end-to-end against live server: getComputedStyle on a probe
.msg-tts-btn returns display:flex when body has tts-enabled, display:none
when it doesn't. Two regression tests in TestIssue1409TtsToggleBodyClass
explicitly check for the body-class shape and forbid the broken inline-style
pattern.
Issue #1410 — Ollama (local) shows "API key configured" when only
Ollama Cloud key is set
=================================================================
Reported via Discord: configuring Ollama Cloud lit up the local Ollama card
too. Both providers were mapped to OLLAMA_API_KEY in api/providers.py
_PROVIDER_ENV_VAR.
Root cause:
api/providers.py:47-48
"ollama": "OLLAMA_API_KEY",
"ollama-cloud": "OLLAMA_API_KEY",
_provider_has_key("ollama") found the value the user set for Ollama Cloud
and returned True. But the runtime code path in
hermes_cli/runtime_provider.py only consumes OLLAMA_API_KEY when the base
URL hostname is ollama.com (Ollama Cloud) — local Ollama is keyless by
default and reaches a custom base URL with no auth. The WebUI was
reporting "configured" for a key local Ollama doesn't even read.
Fix (Option A from the issue body, preferred):
- Drop bare "ollama" from _PROVIDER_ENV_VAR with an inline comment
explaining why
- _provider_has_key("ollama") falls through to the config.yaml branch,
which already supports providers.ollama.api_key for local users who
genuinely need to set a token
- ollama-cloud retains its OLLAMA_API_KEY mapping unchanged
Verified end-to-end against live server with OLLAMA_API_KEY=sk-cloud-key-test
in env: GET /api/providers reports has_key=True only for ollama-cloud, and
has_key=False for bare ollama. Two regression tests in
TestIssue1410OllamaEnvVarBleed cover the bleed-prevention case AND the
"local user with config.yaml api_key still reports configured" case to
guard against over-correction.
Tests
-----
3572 passed, 2 skipped, 3 xpassed (was 3567 — added 5 new regression tests).
Closes#1409Closes#1410
Reported by @AvidFuturist (Discord, May 1 2026)