fix(themes): an imported theme keeps its colours under the semantic layer - #689
Merged
Conversation
added 4 commits
August 20, 2026 01:59
…ayer A semantic token whose type the active theme does not name does not fall back to the grammar's colour — it resolves to the theme's *root* rule, whose foreground is a real colour id rather than "none", and `sparseTokensStore` then masks the grammar's foreground out and paints that plain default over it. `getTokenStyleMetadata` also reports the four font styles as booleans rather than "not set", so bold, italic, underline and strikethrough are taken over and switched off with it. The two built-in themes are handed `semanticTokenRules()` and name every type. An imported VS Code theme was handed `monacoTokenRules()` alone, which renames scopes to the names the *grammar* emits. Of the kinds the extractor sends, exactly one matched — `strong`, and only because #676's `markup.bold` alias happens to collide with the legend's spelling. So since the semantic layer shipped, an imported theme has been losing its heading, list, quote, table, inline-code, link and image colours to the default foreground, and `~~strike~~`, `==highlight==` and `++insert++` their font styles with them. Two halves. The alias table now carries the construct names beside the grammar ones, so a theme's own `markup.italic`, `markup.strikethrough`, `markup.heading`, `markup.quote`, `markup.list` and the rest reach the layer that reports them — which is the part of #676 that could not be answered while the grammar decided the colours, since its tokenizer has no name for a strikethrough at all. And `importedThemeRules` puts the app's semantic rules underneath the theme's, so a construct with no `markup.*` scope of its own — a task checkbox, a table, a wikilink, maths — takes a deliberate colour instead of the default. The test resolves every kind the extractor emits through Monaco's own theme trie and fails on any that lands on the fallback rule, because this failure is invisible from either file alone: the rules look complete on one side and the kinds look covered on the other.
The alias reached the content and stopped there, so a theme coloured a word and the app went on colouring the markup around it: `~~` in one grey and the struck word in another, `##` blue against a red title, `[` purple against a cyan link text. Insertion order is why. Rules are sorted by name, so `strike` lands before `strike.marker`, and a child node is cloned from its parent at the moment it is created. The theme's `strike` updated the parent, then the app's `strike.marker` — longer, therefore last — created the child from that and overwrote the foreground with its own. The base was supposed to sit underneath the theme and was sitting on top of it for every marker. An alias now names `<kind>.marker` beside `<kind>`, which puts the theme's rule after the app's on both. Swept the rest of the pairs the same way, by resolving every type and its marker through Monaco's trie: with this in, the only construct whose marker and content differ under an imported theme is maths, which is #687 deliberately colouring `\frac` apart from its operands, and the bare `list`, `task`, `quote` and `table` types no extractor span uses. The built-in themes come out uniform on every pair but that same one.
The eight scope/kind pairs were written out in the test, which covers what broke and nothing that might. The defect appears whenever the app's base names a *longer* token than an alias does — so a newly added alias is exactly the case a hardcoded list cannot see, and adding one is the likeliest way to reintroduce this. Iterating the table means an entry is covered the moment it exists. The generated colour is per kind rather than per scope, because two scopes can name the same construct — `markup.inline.raw` and `markup.raw.inline` are both inline code — and giving those two different colours would only have tested which of them sorts last.
… under it The previous commit fixed the markers by having an alias name `<kind>.marker` beside `<kind>`. That works only because the alias table knows how the base is spelled, which is the coupling that produced the bug in the first place — the base and the table each looked complete on their own. Monaco has no notion of one rule set overriding another. Rules are sorted by name and merged into a trie where a child is cloned from its parent when it is created and never revisited. Between two rules of the same name, array order decides and the theme wins; between `strike` and `strike.marker`, the trie shape decides, the longer name is inserted last and overwrites the child, and which set a rule came from does not enter into it. Layering by array order holds for exactly as long as the two sets happen to name the same tokens, and inverts silently the moment the base names a longer one. So the base is rewritten rather than layered under: every rule the app declares for a construct the theme named takes the theme's colour before Monaco sees any of them. The base's shape stops mattering — marker, content, or a name not invented yet. The alias table goes back to saying only which construct a scope means. Font styles stay the base's: `strike` has to be struck through and `insert` underlined whatever colour they take, and a theme that spells out a style still applies it through its own rule below. The scope is matched by the same prefix rule as the grammar aliases, so `markup.bold.markdown` — the commoner spelling — resolves like the bare form. The test now qualifies every other scope to cover both.
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.
Found while drafting the reply to #676, which is why that reply has not been posted: for the reporter's setup — an imported VS Code theme — the semantic layer made things worse, not better.
What happens
A semantic token whose type the active theme does not name does not fall back to the grammar's colour. It resolves to the theme's root rule, and the root's foreground is a real colour id rather than "none":
getTokenStyleMetadatareports the four font styles as booleans rather than "not set", soSEMANTIC_USE_ITALICand friends are always set too, and bold, italic, underline and strikethrough are taken over and switched off along with the colour.Who it hit
The two built-in themes are handed
semanticTokenRules()(Editor.svelte:297,313) and name every type. An imported theme is handedmonacoTokenRules()alone (theme.ts), which renames TextMate scopes to the names the grammar emits. Resolving every legend entry against a theme built that way:strongmatches by coincidence — #676'smarkup.bold→strongalias happens to collide with the legend's spelling for the same construct.markup.italicaliases toemphasisand the legend saysemph; one letter apart, no match.So since the semantic layer shipped, an imported theme has been losing its heading, list, quote, table, inline-code, link and image colours to the plain default foreground, and
~~strike~~,==highlight==,++insert++their font styles with them.The fix
The alias table carries both spellings.
markup.italic→emphasisandemph,markup.inline.raw→variableandcode,markup.underline.link→string.linkandlink, plus the scopes that only the semantic layer can reach:markup.heading,entity.name.section,markup.strikethrough,markup.quote,markup.list,markup.fenced_code,meta.separator.That is the half of #676 that could not be answered before. Monaco's tokenizer has no name for a strikethrough at all, so no rename ever reached it; the parse does, and a theme's
markup.strikethroughnow lands onstrike.importedThemeRulesputs the app's semantic rules underneath the theme's. Monaco merges same-named rules in array order, so the theme still wins wherever it has an opinion. A construct with nomarkup.*scope of its own — a task checkbox, a table, a wikilink, maths — takes a deliberate colour instead of the theme's default foreground.Test
no kind falls through to an imported theme that has nothing to saybuilds the theme through Monaco's ownTokenThemeand asserts no kind the extractor emits resolves to the fallback rule. It asks Monaco rather than re-implementing the lookup, because re-implementing it is how you write a test that agrees with itself.Confirmed it fails without the fix (
actual: ['heading', 'list', 'task', 'quote', …]), which is the shape of the shipped bug.The kinds are read out of
semantic.rsrather than fromTOKEN_TYPES, deliberately:list,task,quoteandtableare legend types with markers only, and asserting on the legend would demand rules for content spans that are never sent.Verification
🤖 Generated with Claude Code