Skip to content

fix(themes): an imported theme keeps its colours under the semantic layer - #689

Merged
PathGao merged 4 commits into
masterfrom
fix/imported-theme-semantic-rules
Aug 19, 2026
Merged

fix(themes): an imported theme keeps its colours under the semantic layer#689
PathGao merged 4 commits into
masterfrom
fix/imported-theme-semantic-rules

Conversation

@PathGao

@PathGao PathGao commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

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":

// semanticTokensProviderStyling.js
if (tokenStyle.foreground) {                       // a ColorId; the root's is 1, always truthy
    metadata |= foregroundBits | SEMANTIC_USE_FOREGROUND;
}

// sparseTokensStore.js
bMask |= (bMetadata & SEMANTIC_USE_FOREGROUND) ? FOREGROUND_MASK : 0;
aMask = (~bMask) >>> 0;                            // the grammar's colour is masked out

getTokenStyleMetadata reports the four font styles as booleans rather than "not set", so SEMANTIC_USE_ITALIC and 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 handed monacoTokenRules() alone (theme.ts), which renames TextMate scopes to the names the grammar emits. Resolving every legend entry against a theme built that way:

命中:  strong, strong.marker
落空:  heading list task quote rule fence frontmatter table code emph
       strike highlight insert link image math wikilink footnote html
       … 以及它们的 .marker

strong matches by coincidence — #676's markup.boldstrong alias happens to collide with the legend's spelling for the same construct. markup.italic aliases to emphasis and the legend says emph; 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.italicemphasis and emph, markup.inline.rawvariable and code, markup.underline.linkstring.link and link, 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.strikethrough now lands on strike.

importedThemeRules puts 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 no markup.* 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 say builds the theme through Monaco's own TokenTheme and 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.rs rather than from TOKEN_TYPES, deliberately: list, task, quote and table are legend types with markers only, and asserting on the legend would demand rules for content spans that are never sent.

Verification

npm run check         809 files, 0 errors, 0 warnings
npm test              950 pass
npm run test:vitest   376 pass
cargo test            161 pass
cargo clippy          -D warnings, clean

🤖 Generated with Claude Code

PathGao 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.
@PathGao
PathGao merged commit cebd94d into master Aug 19, 2026
4 checks passed
@PathGao
PathGao deleted the fix/imported-theme-semantic-rules branch August 19, 2026 18:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant