12 min read

Support dark theme in editor

Table of Contents

Working notes on mozilla/pontoon#4001, shipped in PR #4176. The goal was to make the Pontoon translation editor follow dark mode like the rest of the app.

Summary

Pontoon already had a light and dark theme for the overall interface. The translation editor in the middle column was the exception. It is a CodeMirror text area, and its colors were hard-coded to a light palette, so it stayed bright white even when a user had switched everything else to dark. That looked broken, and it was hard on the eyes when translating at night.

The fix has two halves. The first half themes the editor properly by pointing its CodeMirror surfaces at CSS variables that already existed for the other inputs in the app, then filling in real dark values for those variables. The second half adds a per-user editor theme setting with three options (dark, light and match) so that someone can run the whole app in dark mode but keep the editor light if that is easier to read, or the other way around.

Everything below comes straight from the merged diff, which is fully open source.

The data model

The setting lives on UserProfile as a new editor_theme field. It is nullable on purpose. A NULL value means the user has never picked anything, which is different from a user who deliberately chose one of the options. Keeping those two cases separate means the default can change later without quietly overriding people who made an explicit choice.

# pontoon/base/models/user_profile.py
class EditorThemes(models.TextChoices):
    DARK = "dark", "Dark"
    LIGHT = "light", "Light"
    MATCH = "match", "Match main"

DEFAULT_EDITOR_THEME = EditorThemes.LIGHT

editor_theme = models.CharField(
    choices=EditorThemes.choices,
    max_length=20,
    null=True,
    blank=True,
    default=None,
)

The matching migration just adds that column with the same three choices.

# pontoon/base/migrations/0117_userprofile_editor_theme.py
migrations.AddField(
    model_name="userprofile",
    name="editor_theme",
    field=models.CharField(
        choices=[("dark", "Dark"), ("light", "Light"), ("match", "Match main")],
        blank=True, null=True, default=None, max_length=20,
    ),
),

The endpoint

Saving the choice goes through a small POST /user/editor-theme/ view. It reads the value off the request, validates it against the model choices with full_clean(), and saves. If anything is missing or invalid it returns a 400 with a JSON body. The whole thing follows the shape of the existing toggle_theme view that already handled the main interface theme, so it stays consistent with what was there.

# pontoon/contributors/views.py
@login_required(redirect_field_name="", login_url="/403")
@require_POST
@transaction.atomic
def toggle_editor_theme(request):
    editor_theme = request.POST.get("editor_theme", None)

    if not editor_theme:
        return JsonResponse(
            {"status": False, "message": "Bad Request: editor_theme is required"},
            status=400,
        )

    try:
        profile = request.user.profile
        profile.editor_theme = editor_theme
        profile.full_clean()          # rejects anything outside the choices
        profile.save()
    except ValidationError as e:
        log.error(f"User profile validation error: {e}")
        return JsonResponse(
            {"status": False, "message": "Bad Request: User profile validation failed"},
            status=400,
        )

    return JsonResponse({"status": True})

The route is wired up next to the other user settings endpoints.

# pontoon/contributors/urls.py
path(
    "user/editor-theme/",
    views.toggle_editor_theme,
    name="pontoon.contributors.toggle_editor_theme",
),

Resolving the theme to render

Templates need a single value to render, but there are a few cases to handle. The user might be anonymous. A logged-in user might never have chosen, or might have made a real choice. A small helper folds the first two cases into the default and returns the stored value otherwise. Pontoon renders some templates with Jinja and some with Django, so the helper is registered in both, with the same logic in each.

# pontoon/base/templatetags/helpers.py  (Jinja)
@library.global_function
def user_editor_theme(user):
    """Resolve the editor theme to apply.

    Falls back to the default theme when the user is anonymous or has not yet
    made an explicit choice (``editor_theme`` is NULL).
    """
    from pontoon.base.models import UserProfile

    if user.is_authenticated and user.profile.editor_theme:
        return user.profile.editor_theme
    return UserProfile.DEFAULT_EDITOR_THEME

That resolved value gets written onto the <body> tag as a data-editor-theme attribute. Doing it server-side means the value is present on the very first paint, so the editor does not flash the wrong color before JavaScript runs.

<!-- pontoon/base/templates/base.html -->
<body
  ...
  data-theme="{{ user_theme(request.user) }}"
  data-editor-theme="{{ user_editor_theme(request.user) }}"
>

Settings UI on the server side

The settings page already had a theme toggle macro for the main interface, with three buttons for dark, light and match system. Rather than copy that markup, the macro picks up an editor flag. When the flag is on it renders the editor variant, where the third button becomes “Match main interface” instead of “Match system” and gets its own icon and tooltip text. One macro now drives both toggles.

{# pontoon/base/templates/widgets/theme_toggle.html #}
{% macro button(user, editor=False, long_name=True) %}
  {% set current = user_editor_theme(user) if editor else user.profile.theme %}
  ...
  {% if editor %}
    {% set third_value = "match" %}
    {% set third_label = "Match main interface" if long_name else "Match" %}
    {% set third_icon = "fas fa-link" %}
  {% else %}
    {% set third_value = "system" %}
    {% set third_label = "Match system" if long_name else "System" %}
    {% set third_icon = "fas fa-laptop" %}
  {% endif %}
  ...
{% endmacro %}

The settings page then drops in two labelled fields, one calling the macro normally and one calling it with editor=True.

{# pontoon/contributors/templates/contributors/settings.html #}
<div class="field theme-field">
  <p class="theme-label">Main interface</p>
  {{ ThemeToggle.button(user) }}
</div>
<div class="field theme-field editor-theme-field">
  <p class="theme-label">Editor</p>
  {{ ThemeToggle.button(user, editor=True) }}
</div>

Clicks on the editor buttons are handled by their own AJAX function. It posts the new value, and on success it flips the active button and updates the data-editor-theme attribute on the body. There was one gotcha here. The existing main-theme handler matched all the toggle buttons on the page, so it would have fired on the editor buttons too. It now bails out early if the click came from inside .editor-theme-field, which keeps the two toggles from stepping on each other.

// pontoon/base/static/js/theme-switcher.js
$('.appearance .toggle-button button').click(function (e) {
  const self = $(this);
  if (self.closest('.editor-theme-field').length) {
    return; // editor toggle has its own handler below
  }
  ...
});

$('.appearance .editor-theme-field .toggle-button button').click(function (e) {
  e.preventDefault();
  e.stopPropagation();
  const self = $(this);
  if (self.is('.active')) return;

  const editorTheme = self.val();
  const editorSelector = '.appearance .editor-theme-field .toggle-button button';

  $.ajax({
    url: '/user/editor-theme/',
    type: 'POST',
    data: {
      csrfmiddlewaretoken: $('body').data('csrf'),
      editor_theme: editorTheme,
    },
    success: function () {
      $(editorSelector).removeClass('active');
      $(`${editorSelector}[value=${editorTheme}]`).addClass('active');
      $('body').attr('data-editor-theme', editorTheme);
    },
    error: function () {
      Pontoon.endLoader('Oops, something went wrong.', 'error');
    },
  });
});

The React side

The translation app itself is React, and it needs the same value to drive its in-editor controls. ThemeContext now carries editorTheme alongside the existing theme, plus a setter. The setter does a few things at once. It updates React state so the UI re-renders and writes the DOM attribute so the CSS reacts immediately. It also posts to the backend so the choice persists across sessions.

// translate/src/context/Theme.tsx
const [editorTheme, setEditorThemeState] = useState(
  () => document.body.getAttribute('data-editor-theme') || 'light',
);

const setEditorTheme = useCallback((newEditorTheme: string) => {
  setEditorThemeState(newEditorTheme);
  document.body.setAttribute('data-editor-theme', newEditorTheme);
  updateUserEditorTheme(newEditorTheme); // POST /user/editor-theme/
}, []);

The API call is a thin wrapper that posts the value with the CSRF token, matching how the other user endpoints are called from the front end.

// translate/src/api/user.ts
export function updateUserEditorTheme(editorTheme: string): Promise<void> {
  const csrfToken = getCSRFToken();
  const payload = new URLSearchParams({
    editor_theme: editorTheme,
    csrfmiddlewaretoken: csrfToken,
  });
  const headers = new Headers({ 'X-CSRFToken': csrfToken });
  return POST('/user/editor-theme/', payload, { headers });
}

Inside the editor, the gear menu gets a new theme section. The three options are described once in a small config array, which keeps the button markup short and avoids repeating the same JSX three times. Each button reads from editorTheme to decide whether it is active and calls setEditorTheme on click.

// translate/src/modules/editor/components/EditorSettings.tsx
const EDITOR_THEME_OPTIONS = [
  { value: 'dark',  id: 'editor-EditorSettings--theme-dark',  icon: 'far fa-moon', label: 'Dark',  title: 'Use a dark editor' },
  { value: 'light', id: 'editor-EditorSettings--theme-light', icon: 'fas fa-sun',  label: 'Light', title: 'Use a light editor' },
  { value: 'match', id: 'editor-EditorSettings--theme-match', icon: 'fas fa-link', label: 'Main',  title: 'Use a theme that matches the main interface' },
] as const;

// ...
const { editorTheme, setEditorTheme } = useContext(ThemeContext);
// ...
{EDITOR_THEME_OPTIONS.map(({ value, id, icon, label, title }) => (
  <button
    type='button'
    value={value}
    className={`${value} ${editorTheme === value ? 'active' : ''}`}
    title={title}
    onClick={() => setEditorTheme(value)}
  >
    {`<glyph></glyph> ${label}`}
  </button>
))}

The part that makes it all work

Here is the idea the whole feature rests on. Pontoon defines its color palettes as CSS variables scoped under a .light-theme or .dark-theme class on the <body>. Every variable like --editor-background gets its value from whichever theme class is active. To let the editor differ from the page around it, the editor wrapper gets its own theme class. That re-declares those same variables, but only for the editor subtree, so the editor can be dark while the page is light without touching the rest of the page.

EditField computes that class. It only adds one for the explicit dark and light choices. The match option adds nothing, which lets the editor inherit whatever the page theme already is.

// translate/src/modules/translationform/components/EditField.tsx
const { editorTheme } = useContext(ThemeContext);

const editorThemeClass =
  editorTheme === 'dark' || editorTheme === 'light'
    ? `${editorTheme}-theme`
    : '';

return (
  <div
    className={
      [readOnly && 'readonly', editorThemeClass].filter(Boolean).join(' ') || undefined
    }
    ...
  >

The accesskey input gets the same class in EditAccesskey.tsx so it tracks the editor too. The chrome around the editor, like the editor menu and the translation-memory source bar, has to follow along as well. Those selectors combine the page theme class with the editor override so the surrounding bars match whichever surface the editor is showing.

/* translate/src/modules/editor/components/EditorMenu.css */
.dark-theme[data-editor-theme='dark'] .singlefield ~ .editor-menu,
.light-theme[data-editor-theme='light'] .singlefield ~ .editor-menu,
[data-editor-theme='match'] .singlefield ~ .editor-menu {
  background: var(--editor-background);
}

Wiring CodeMirror to the variables

CodeMirror was drawing the cursor, the text selection, and the placeholder with its own built-in colors, which did not change with the theme. Pointing those at the editor variables is what makes them invert correctly. When the theme flips, the cursor and selection flip with it.

/* translate/src/modules/translationform/components/TranslationForm.css */
.cm-editor .cm-content { caret-color: var(--editor-color); }

.cm-editor .cm-cursor,
.cm-editor .cm-dropCursor { border-left-color: var(--editor-color); }

.cm-editor ::selection { background: var(--editor-selection-background); }

.cm-editor .cm-selectionBackground,
.cm-editor.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground {
  background: var(--editor-selection-background);
}

.cm-editor .cm-placeholder { color: var(--editor-placeholder-color); }

The tag string text inside the editor picks up a new variable as well, so placeholders and tags stay readable in both themes.

/* translate/src/modules/translationform/utils/editFieldExtensions.css */
.tags-string {
  color: var(--tags-string-color);
  white-space: pre-wrap;
}

The palette values

This is where the original bug actually lived. The dark theme had left the editor variables on light values, so --editor-background was white and --editor-color was a dark grey even in dark mode. Those are reworked to proper dark values. A few new variables are added for the selection background, the placeholder color and the tag string color, since the editor now needs them. The tag and diff highlight colors are also brightened so they stay legible against a dark surface.

/* pontoon/base/static/css/dark-theme.css */
--editor-background: #1a1d22;          /* was #ffffff */
--editor-color: #e6e6e6;               /* was #444444 */
--editor-readonly-background: #2a2e35; /* was #c7cacf */
--editor-selection-background: #2c4a66;
--editor-placeholder-color: #888c92;

--diff-del-background: #5e3b46;
--diff-del-color: #ff9d9d;
--tags-brace-color: #c79bff;
--tags-bracket-color: #5fc7a8;
--tags-string-color: #e6e6e6;

The light theme gains the exact same new variables, plus a few small contrast tweaks. The point is that both themes now expose an identical set of variables, so the editor can switch between them cleanly without any variable coming up undefined.

/* pontoon/base/static/css/light-theme.css */
--editor-selection-background: #bee2f8;
--editor-placeholder-color: #6f7479;
--tags-string-color: #2a2a2a;
--diff-del-color: #c61b3e;   /* darkened for contrast */

Tests

The endpoint is covered for the cases that matter. Each valid choice saves and reads back, an invalid value is rejected with a 400 while the stored value stays put and a missing value is rejected too.

# pontoon/contributors/tests/test_views.py
@pytest.mark.django_db
def test_toggle_editor_theme(member):
    url = "/user/editor-theme/"
    assert member.user.profile.editor_theme is None

    for value in ("dark", "light", "match"):
        response = member.client.post(url, {"editor_theme": value})
        assert response.status_code == 200
        assert response.json() == {"status": True}
        member.user.profile.refresh_from_db()
        assert member.user.profile.editor_theme == value

    # invalid choice is rejected and the stored value is unchanged
    response = member.client.post(url, {"editor_theme": "neon"})
    assert response.status_code == 400
    assert response.json()["status"] is False
    member.user.profile.refresh_from_db()
    assert member.user.profile.editor_theme == "match"

The resolver helper has its own tests for the unset, explicit and anonymous cases, which lock in the fallback behavior described earlier.

# pontoon/base/tests/test_helpers.py
def test_user_editor_theme_unset_resolves_to_default(member):
    assert member.user.profile.editor_theme is None
    assert user_editor_theme(member.user) == UserProfile.DEFAULT_EDITOR_THEME

def test_user_editor_theme_anonymous_resolves_to_default():
    anon = AnonymousUser()
    assert user_editor_theme(anon) == UserProfile.DEFAULT_EDITOR_THEME

Takeaways

The biggest lesson here was that theming the editor turned out to be much less work than it first looked. Most of the variables already existed for the other inputs, so the real job was pointing CodeMirror at them and filling in the dark values that had been left on light defaults.

The trick that made an independent editor theme possible was re-scoping the CSS variables on a subtree. Putting a ${editorTheme}-theme class on the editor wrapper redeclares the palette just for that part of the page, so the editor and the page can disagree without duplicating any styles.

Storing NULL for users who never chose kept that case distinct from a real choice, which is what lets the default move later without rewriting anyone’s saved setting.

One last thing. Pontoon renders templates with both Jinja and Django, so the resolver helper had to be registered twice, once in helpers.py and once in django_helpers.py. Forgetting one of them would have left half the pages without the attribute.

The full working notes and screenshots are in the embedded PDF below.

Your browser can't display this PDF inline. Download the PDF.