Styling and theming design discussion

I’m posting this to invite discussion and feedback of CodeMirror 6’s CSS system. People have been having some difficulty with it, and I want to outline the constraints it exists under and describe a possible change in the design.

We’re not going to “just use” plain CSS files

Firstly, please keep any knee-jerk hostility to CSS-in-JS out of the thread. We have the following requirements:

  • Styles will be distributed over various packages. Users can not be expected to manually include the styles for all the packages they (transitively) load, and bundlers don’t (yet) provide a standardized way to bundle styles.

  • I’d really like to have proper isolation/scoping on CSS class names. Something like that is necessary for themes in any case. Being able to load multiple versions of the library without those interfering with each other is a plus.

Especially the first point just rules out plain old CSS files. And if we need to integrate this with the scripts in order to piggyback on JavaScript dependency management, we might as well use some of the options dynamic CSS provides us with.

That isn’t to say client code integrating CodeMirror can’t use plain CSS files. Making that possible is one of the goals. More about that in a moment.

The current system

What the library does right now is prefix the rules generated for themes and base themes with an additional class selector. For themes, that class is tied to the theme, and only added to the view’s outer node when the theme is active. For base themes (which are always supposed to be active, as a fallback base style), the prefixed class is shared between all the base themes and always added to the editor (but still generated to be unique for a given instance of the @codemirror/view library).

“Theme selectors” are a concept used to normalize class names (always prefixing "cm-") and allow simple hierarchical relations between selectors: "panel.search" expands to "cm-panel cm-panel-search", so that it inherits the styles from its parent selector but is itself more specific, so that rules for that selector override rules for the base class.

The predictable class names produced by theme selectors allow external style sheets to target editor elements.

This satisfies the requirements above, but has a few downsides:

  • The library generates rather specific rules, which are a bit of a pain to override in plain CSS. I.e. .ͼ4.cm-light .cm-activeLine (ͼ indicates a generated class) has three class selectors in it, so, though the injected styles are put at the start of the page and thus can be overridden in rules defined elsewhere that have the same precedence, you do need to add an extra uninteresting class (for example .cm-wrap.cm-light .cm-activeLine) to override them.

  • To be able to add the prefix in the proper way, the library has to know when a rule includes a selector for the top element. I.e. for $activeLine (where $ indicates this is a theme selector), the prefix needs to have a space after it (.ͼ2 .cm-activeLine) for $focused (which is added to the top element when the editor is focused) there should be no space (.ͼ2.cm-focused). This is confusing and the current solution (adding an extra $ to indicate you’re targeting the top element) is hideous.

  • This kind of rule matching via classes on ancestors is a bit more expensive to do for the CSS engine compared to rules that just directly target a class.

Syntax highlighting

The highlighter uses a somewhat different system. Highlight styles define anonymous CSS classes for every tag they define. The highlighter emits the most specific matching style class (if any) for every bit of code.

Because the highlighting info is quite a bit more rich than what a typical highlighter will use, the editor does not currently emit fixed classes for all this information, because it would be a bit much. You’d get something like this for let x = [0];:

<span class="cm-keyword cm-definitionKeyword">let</span>
<span class="cm-name cm-variableName cm-variableName-definition">x</span>
<span class="cm-operator cm-definitionOperator">=</span>
<span class="cm-punctuation cm-bracket cm-squareBracket">[</span>
<span class="cm-literal cm-number cm-integer">0</span>
<span class="cm-punctuation cm-bracket cm-squareBracket">]</span>
<span class="cm-punctuation cm-separator">;</span>

That seems excessive. Producing a somewhat lightweight DOM is one of my design goals. Hence the use of a different approach here. I do intend to add a way to create a highlighting style that just adds a (more limited) set of fixed classes for situations where you really want to use plain CSS to style code.

The disconnect between the way syntax (directly adding anonymous classes to targeted elements) is styled and the way the rest of the editor works (toggling a class on the outer element to enable rules targeting .ͼX .cm-something) is a bit annoying.

Options

I’m considering two approaches to improve the situation. Feel free to propose other ideas if you think they might be helpful.

Minimalist

Mostly keep things as they are.

Replace the extra-$ syntax in themes with & to make it slightly less confusing (& for “parent selector” kind of has a precedent in the nested rule syntax).

Add a highlight style that styles tokens with fixed class names. Probably also add a way to apply multiple highlight styles at the same time.

Explain in the docs that to override styles generated by the scripts you’ll have to figure out how specific they are (easiest done in the devtools) and replace the ͼ class with cm-wrap to keep the same specificity.

Back to directly applied classes

Up to about a year ago, themes worked differently. There was a method on the view that you’d pass a theme selector ID, and it’d return a string containing the class names that the active themes assigned to that selector. Code creating DOM elements would call this method to compute a class attribute for their elements.

The main downside of this approach was that it gets awkward to support dynamic theme changes—if there’s classes computed from the current theme all over the editor DOM, and all those classes have to be updated when the theme configuration changes. With a system that just toggles a class on some parent element, that problem doesn’t exist.

Still, this could be solved by just redrawing the entire view DOM when the theme changes (which isn’t that expensive either, and likely rare). View plugins would be torn down and reinitialized—if they are well-behaved plugins that keep their state in the editor state, this shouldn’t be disruptive.

(Somewhat related, I realized that there’s another situation that’ll require a solution like that anyway—currently, if you change the phrases facet, your editor will not dynamically update its translateable text. It should. And the most practical way I can see to do that would be a redraw.)

In this system, theme definitions would work differently—every style provided by a theme would have to be scoped under some theme selector (say line or panel) so that its generated class could be added to those specific elements. You could still create more complex rules, like styling things differently when the editor is focused, using nested rules with & notation. The $ notation could go away entirely.

In the current code, the only place where class names are directly embedded in the editor state (thus making it harder to update them when the theme changes) is in decorations. Adding direct support for specifying theme selectors to decorations, instead of requiring a literal class name to be provided, would make that problem easy to avoid.

An advantage of this approach would be that highlighting and theming become quite a lot more similar—both add class names to the relevant elements. You could say that code highlighting just doesn’t omit the stable classes by default as an optimization (but can be configured to emit them).

2 Likes

I tried reading this post in full twice and I’m still having some difficulty understanding it :joy:. That is not to say anything you wrote was unclear; this is just a very difficult challenge with many complex trade-offs.

I suspect many others after reading this post will feel similarly powerless to contribute, given the rarity of developers at @marijn 's level (I know I’m not…), with sufficient amount of time to deep dive into CM6’s internals.

Perhaps to help others participate, it would make things easier if there are some concise and summarized points, ideally phrased from the user’s perspective. (By user I mean developer who will be using CM6 in their projects)


After testing the waters with cm6 (and deciding to go ahead actually using it in production), here’s how I see the problem: I think the theme system and the highlighting system are two sets of challenges, while with some overlap, but both have possibly different design goals.

Theme system

Honestly I’m ok with the way it is right now. It’s certainly annoying and cumbersome at times to write more specific rules to override the base theme set by cm6, but it’s still doable.

I can’t think of a better way to provide isolation between different editors on the same page. In the old days of CM5 it’d just be adding a custom class to the editor anyway.

Syntax highlighting

I think this is where people ran into most trouble. The lack of a persistent class name made some things (like debugging language parsers) difficult, and others (using css files to style syntax highlighting) practically impossible.

I understand this opens up the possibility of isolation between editors, but I think it’s still possible to get this benefit with fixed class names by prepending the randomly generated class from the wrapper.

Personally, I’ve had to make a fork of the language parser to generate tokens with a “css class” attribute rather than a Tag attribute, and I’ve then forked the highlighter plugin to make use of that css class attribute. This worked well for us, but unfortunately isn’t a great idea in the long term for maintainability.

I still prefer the old style “by string convention” where it was rather easy to add new strings to both a language parser, and the highlighter theme. However, I do understand that given CodeMirror is primarily targeted at code editing, having custom token types (highlighting wise) is probably not a design goal.

Thanks for responding.

This should be addressed by the proposed feature of a highlight style that emits stable classes (which will be done regardless of which approach I take here).

This is still not hard—export your custom Tag instances for your custom tokens from your language package, and target them your highlighter.

1 Like

Sounds great!

This should be easier once the stable classes are available. Was there any change recently to support line classes? I’m doing something similar from the highlighter plugin.

See 0.18.0 for a summary of what I ended up doing. It should address some of these concerns.

1 Like

On .ͼ4.cm-light .cm-activeLine vs .ͼ2 .cm-activeLine:
What if you added an extra wrapper whose sole purpose is getting the generated class, and no other classes? If doable, you’d get a consistent .ͼ4 .cm-light .cm-activeLine vs .ͼ2 .cm-activeLine.
[I’m not following the whole picture and might be missing reasons against this…]

Thanks for the suggestion. I considered that, but wasn’t comfortable with that additional bit of spurious DOM complexity, especially since people will sometimes want to run querySelector and similar on the actual outer element of the editor, and having a separate class for that seems annoying.

I have a solution that uses stable css variable names that can be managed via (s)css.

  const myHighlightStyle = HighlightStyle.define(  Object.keys(tags).map( name=>{ return {
    tag: tags[name], color: `var(--editor-style-${name})`
    }}));

Which makes the generated classNames use variable name like --editor-style-string for string. This was easier to manage in my existing application context than defining styles via js. Also dark / light mode managing becomes easy.

The Object.keys(tags) can be filtered via filter if not all tags are needed, using all was a good starting point for me.

2 Likes

The primary difficulty for me in this model is that I am using variables and processing in my CSS to make sure things like colors are consistent. The CSS-in-JS is a bit of an all-or-nothing question in that respsect, based on my understanding.

I do understand the reasoning for the theming, I think for me I would like to see theming get the same treatment as other functionality: being an explicit opt-in. That way I can easily (for example) remove the component that is applying the font choice if I want to apply my own font.

I think this is trickier with some components as functionality at one point does depend on positioning. But I think that a lot of people’s more bespoke requirements are easier to solve if it’s very easy to simply opt out of some of the styling that is applied

EDIT: just to add some examples of styles that are posing problems for me in a CM5 → CM6 port, where if I could “opt out” of the styling on those things in particular I would have no issues:

  • the default font being set to monospace
  • padding values on various gutters and lines
  • the line height

Meanwhile I am setting various colors and the like for syntax highlighting, using the stable class plugin. Colors are a big thing where existing projects (tailwind users?) are tougher I think. Unless I’m missing some trick here

Why does overriding those rules in your own CSS not work for you?

Style specificity means that short of using !important we can’t write CSS that will be more specific than the generated CSS.

!important is, of course, an option. But personally my big worry is that if you’re doing everything with !important, then at one point if you need something really important, you now have a specificity issue to solve and you don’t have the most useful ‘resolve this edge case’ option.

Is there maybe a way to specify a theme that would wipe stuff from the base, without having to specify? Instead of setting the font in the CSS-in-JS, having like… Theme({"cm-editor": {"font": "unset"}}) essentially causing that key to get wiped (this isn’t the right selector ofc) would be nice. But I’m just daydreaming unserious solutions here.

Full disclosure: in my case with Tailwind I think that I can resolve my problems by using some tailwind CSS-in-JS solution to do something workable here. I think part of my problem is I’m experiencing this with the CM 5 → 6 transition so I am having to handle this on top of everything else.

That is not true. The generated CSS adds a generated prefix class, but you can use .cm-editor instead of that to get the same specificity, which in most cases means your styles will get precedence.

Empirically I was not seeing this, but your comment gives me the (now obvious in retrospect) idea of simply adding another CSS class to bump up the precedence of what I want to do.

One odd thing with the current theme system is the case of multiple editors on the page. In case of notebooks, this can be hundreds. Usually this does not matter - one can just work with CSS variables, but there is at least one edge case.

The lint plugin uses background-image with SVG embedded via CSS. To override it I listen to changes in theme of the application and reconfigure the base theme accordingly. Now, because I cannot use a variable inside CSS url(), I have to go and dispatch the update to every editor on the page (say a hundred of them). It may seem spurious because there is only one style tag (as far as I understand), but I still need to udpate all views because otherwise if I add another style module in a view that was not updated, it will undo my changes.

My problem is that there is no true “shared” theme that can be updated for all editors at once. But then, it is only a provlem under so specific constraint that probably no one else cares.

Sounds like, in your case, directly manipulating style sheets outside of the editor theme system might work best.

1 Like

Thank you for the reply! Yes, this roughly is what I ended up doing:

  • pulll the variable values from body
  • create a new EditorView.baseTheme with new underline styles
  • create a temporary editor view with said theme
  • extract facet EditorView.styleModule from the temporary editor
  • extract the rules from styleModlues
  • inject them into a singleton global style element

the dance around temporary editor/style module is done to extract precisely the same selectors even if they were to change upstream.

One tiny thing to make life easier here would be if underline was exported (but then its tiny).

You shouldn’t need to get the temp class names from the editor. Just substitute .cm-editor for them and you should have the same specificity.

1 Like