fix(editor): stop Monaco outlining fullwidth CJK punctuation - #462
Merged
PathGao merged 1 commit intoAug 5, 2026
Merged
Conversation
Monaco's Unicode highlighter is built for source code, where a character that looks like ASCII but is not is an attack vector. Markpad is a Markdown editor, and `monaco.editor.create` has never passed `unicodeHighlight` — no occurrence anywhere in the repo — so Monaco's defaults have been in charge for the editor's whole life. Two reports are the same defect: sftwrdotdev#186 ("there are highlighted boxes in v2.6.11") and sftwrdotdev#94 ("Chinese punctuation marks are displayed inside a box"), also raised as the first half of sftwrdotdev#393. `ambiguousCharacters` defaults to true, and `,` `!` `?` `(` `)` `;` `:` are each a confusable of an ASCII counterpart, so each gets a box. The highlight is suppressed when the surrounding word is entirely non-ASCII (`shouldHighlightNonBasicASCII`, common/services/unicodeTextModelHighlighter.js), which is why pure CJK looks clean and the boxes appear only once basic ASCII shares the word with the punctuation. That is the normal case in CJK technical prose — `使用 Monaco,然后保存。`, `安装依赖(npm ci),再运行测试。` — and `**粗体**,` triggers it with no Latin word at all, because a Markdown emphasis marker is basic ASCII. `invisibleCharacters` stays at its default. A stray zero-width space or NBSP is a real hazard in Markdown — an NBSP after `-` stops the list from parsing — and unlike the punctuation it is never something the author typed on purpose, so it is the part of this feature that earns its place in a prose editor. Disabling `unicodeHighlight` wholesale would have taken it too. `nonBasicASCII` needs no setting. It defaults to the `inUntrustedWorkspace` sentinel, which resolves through `!trusted`, and standalone Monaco's `StandaloneWorkspaceTrustManagementService.isWorkspaceTrusted()` returns true unconditionally — so it is already off. Were it on, every ideograph would be boxed rather than the punctuation, which is not what either reporter described. Pinning it would be a no-op today and would freeze a default the embedder is entitled to change. `allowedCharacters` was the narrower alternative and is worse: it needs an enumerated list of every fullwidth form across Chinese, Japanese and Korean input, and boxes whatever the list forgets. Unconditional rather than gated on the document or the UI language. Monaco already has the mechanism such a gate would use — `allowedLocales`, seeded from the OS locale — and it does not help: `使用 Monaco,然后保存。` boxes identically under en, zh-CN, zh-Hans, ja-JP and ko-KR. A gate would also make the same file render differently depending on state, and would flip mid-typing as the first CJK character arrived. The editor is the only Monaco instance in the app — the preview is a Rust `render_markdown` HTML pipeline and there is no diff editor — so there is nowhere else to apply this. `editor.updateOptions` merges and does not pass `unicodeHighlight`, so setting it once at creation holds. The four tests execute the option object rather than matching source text for a string: the literal is lifted out of the real `monaco.editor.create` call with the TypeScript parser, evaluated, merged over Monaco's shipped defaults through `EditorOptions.unicodeHighlight.applyUpdate`, and run through `UnicodeTextModelHighlighter` — the code that decides what gets a box. One of them asserts the fixtures still reproduce the bug once the option is taken away, so a Monaco upgrade that changed the confusable set fails loudly instead of leaving the other three asserting nothing. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 6, 2026
PathGao
added a commit
that referenced
this pull request
Aug 6, 2026
The review discipline here is real but undocumented: a mechanism section that explains why the old behaviour happened rather than what was done about it, a Scope section saying what was deliberately left alone, a falsification step on fixes, and a Verification section with the exact commands, their counts and an honest list of what was not checked. #468, #464, #462, #460 and #458 all have that shape. Nobody arriving from outside can know it. #463 came close by instinct, which is the argument for writing it down rather than hoping. Five headings, prompts only, no checkboxes. A checkbox that feels mandatory is a required field wearing a disguise, and friction is what makes a contributor abandon a template rather than fill it in; the header says outright that every section can be deleted. No licensing, conduct or "I read the guide" line -- there is no contributing guide to read. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 6, 2026
Closed
PathGao
added a commit
that referenced
this pull request
Aug 8, 2026
…scripts (#546) Both come out of the audit in #393. Neither is a bug in Markpad — they are Monaco defaults that are right for code in English and wrong for prose in a script that behaves differently. ## The space bar under a CJK IME is outlined #462 turned off `unicodeHighlight.ambiguousCharacters` and closed #186 and #94 with it. The boxes came off the fullwidth punctuation and stayed on U+3000 IDEOGRAPHIC SPACE, which is what the space bar produces under every Chinese and Japanese IME — the same reporters, the same typing, the same hover offering an **Adjust settings** button that leads nowhere in standalone Monaco. That character is caught by `invisibleCharacters`, a different flag, whose 465-entry set cannot be narrowed by `allowedLocales`: `strings.js` getData() flattens every locale bucket into one set, so U+3000 is in it unconditionally. Only `allowedCharacters` reaches it. The flag itself stays on, and that is deliberate. An NBSP after `-` stops a list from parsing, and nobody types one on purpose. Worth recording that the comment justifying it was wrong in the other direction: it claimed the highlighter "never fires on something typed on purpose", and U+3000 is exactly that. ## Word-wise navigation treats a Chinese clause as one word ⌥←/→, double-click-to-select and ⌥⌫ split on `wordSeparators`. A language that puts no spaces between its words supplies none, so the whole clause is one word: ⌥→ crosses the sentence, double-click selects it, ⌥⌫ deletes it. `wordSegmenterLocales` switches on `Intl.Segmenter`, which knows where the words are. It reads like a whitelist and is closer to a switch. ICU dispatches its dictionary breaking by SCRIPT, not by this list: Thai, Khmer, Lao, Burmese and Tibetan all segment without being named, and `['zh']`, `['ja']` and `['zh','ja']` produce identical output — including on Han text, where the two dictionaries might have been expected to differ. Pinned in a test, because the obvious tidy-up is to trim the list to match the comment, which would change nothing while making the code claim something false. Space-delimited languages lose nothing: Korean, Vietnamese, Russian and English segment word-for-word identically to splitting on whitespace. That is why this can be on for everyone rather than keyed to the UI language — which would be the wrong key anyway, since it is the document's script that decides, not the language of the menus. ## Tests Driven through Monaco's own code rather than by asserting the options are spelled correctly: `UnicodeTextModelHighlighter.computeUnicodeHighlights` for the first, `getMapForWordSeparators` — the function `wordOperations.js` and `cursorWordOperations.js` both call — for the second. Each fixture is also checked to still reproduce the original bug once the fix is taken away, so a passing test cannot mean the fixture stopped exercising anything. Verified in Node's ICU. The dictionary breaking is standard across ICU builds, but it was not exercised in WKWebView, WebView2 or WebKitGTK. Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #186. Closes #94. First half of #393; the
z-family half (#104) is leftalone, per your "can get to the other Monaco defaults at a later date" on #393.
You queued this one — "if we can fix the punctuation highlights we can queue it"
— the day before v2.7.0, and it did not make the release.
monaco.editor.createhas never passedunicodeHighlight; there is nooccurrence anywhere in the repo, so Monaco's defaults have been in charge for
the editor's whole life.
ambiguousCharactersdefaults to true, and,!?();:are each a confusable of an ASCII counterpart, so eachgets a box. That default is right for source code, where a character that looks
like ASCII but is not is an attack vector, and wrong for a Markdown editor whose
users write prose.
Why it looks intermittent
The highlight is suppressed when the word around the character is entirely
non-ASCII —
shouldHighlightNonBasicASCIIincommon/services/unicodeTextModelHighlighter.jsreturns early on "no basicASCII, and something obviously non-ASCII present". So pure CJK is clean and the
boxes appear only once basic ASCII shares a word with the punctuation:
你好,世界。(测试)!?使用 Monaco,然后保存。,安装依赖(npm ci),再运行测试。(),打开 Settings:Editor,字体大小。:,**粗体**,*斜体*,`代码`。,,The last row is the one that makes this unavoidable: no Latin word is needed,
because a Markdown emphasis marker is itself basic ASCII. Mixing Latin technical
terms into CJK prose is the normal case, not an edge one.
Scope
One sub-option,
ambiguousCharacters: false. The rest are deliberate:invisibleCharactersstays at its default. A stray zero-width space or NBSPis a real hazard in Markdown — an NBSP after
-stops the list from parsing —and unlike the punctuation it is never something the author typed on purpose. It
is the part of this feature that earns its place in a prose editor, and
unicodeHighlight: falsewould have taken it with the boxes.nonBasicASCIIneeds no setting. It defaults to theinUntrustedWorkspacesentinel, which resolves through
!trusted, and standalone Monaco'sStandaloneWorkspaceTrustManagementService.isWorkspaceTrusted()returns trueunconditionally — so it is already off. Were it on, every ideograph would be
boxed rather than the punctuation, which is not what either reporter described.
Pinning it would be a no-op today and would freeze a default you are entitled to
change later.
allowedCharacterswas the narrower alternative and is worse. It needs anenumerated list of every fullwidth form across Chinese, Japanese and Korean
input, and boxes whatever the list forgets.
Unconditional, not gated on language
Monaco already ships the mechanism a language gate would use —
allowedLocales,seeded from the OS locale — and it does not help.
使用 Monaco,然后保存。boxesidentically under
en,zh-CN,zh-Hans,ja-JPandko-KR, which is why thereporters saw boxes on CJK systems in the first place. Beyond that, a content or
UI-language gate would make the same file render differently depending on state,
and would flip mid-typing as the first CJK character arrived.
The editor is the only Monaco instance in the app — the preview is a Rust
render_markdownHTML pipeline and there is no diff editor — so there is nowhereelse to apply this.
editor.updateOptionsmerges and does not passunicodeHighlight, so setting it once at creation holds.Tests
Four tests in
editorOptionWiring.test.ts. They execute the option objectrather than matching the source for a string, which matters here: asserting that
"ambiguousCharacters"appears inEditor.sveltesays nothing about whichcharacters Monaco ends up outlining, and that is the whole contract. The literal
is lifted out of the real
monaco.editor.createcall with the TypeScript parser,evaluated, merged over Monaco's shipped defaults through
EditorOptions.unicodeHighlight.applyUpdate, and run throughUnicodeTextModelHighlighter— the function that decides what gets a box. Aregression has to survive Monaco's own code to reach the assertions.
One of the four asserts the fixtures still reproduce the bug once the option is
taken away, so a Monaco upgrade that changed the confusable set fails loudly
instead of leaving the other three quietly asserting nothing.
Reverting only the option, keeping the tests:
Replacing it with a blanket
{ ambiguousCharacters: false, invisibleCharacters: false, nonBasicASCII: false }instead reddens the other direction — "theinvisible-character warning survives the fix" and the narrowness assertion — so
the pair pins the fix from both sides.
scripts/monacoInternals.d.tsis the one judgement call I would flag. Monacoships declarations for its public API only, so driving the option registry and
the highlighter from a test needs types for two internal module paths.
Declaring them narrowly rather than as
anymeans a shape change is a typeerror and a moved module is a loud import failure; the alternative was to give
up on executing Monaco's real logic and go back to a source-text assertion.
Verification
npm audit,npm run check(0 errors, 644 files),npm test(681 pass), andcargo testall green.Visually: I rendered Monaco 0.55.1 with this exact option object against the
document above, with and without the fix. Default gives 11
unicode-highlightdecorations; with the fix, 1 — the zero-width space, still flagged, which is the
point of keeping
invisibleCharacters. To be precise about what that was: realMonaco in a browser with the same options, not the packaged Tauri app, which
does not run outside its webview (
__TAURI_INTERNALS__is undefined).Co-authored-by: Claude Opus 5 noreply@anthropic.com