enhancement
major
minor
major
minor
Goal
A user who has never chosen a theme is given the configured default one, whatever their operating system is set to. Every other application on their desktop follows that setting; this one does not, and the first thing a dark-mode user sees is a light page.
The theme should follow the system preference until the user overrides it, and an override should be revocable - a user who once picked a theme currently has no way back to "whatever the system says".
Where it stands
The pieces are almost in place, because the themes are already emitted as scoped blocks of CSS custom properties:
- UIThemeService#writeThemeStyles writes one [data-theme="<id>"] block per configured theme, and additionally binds the default theme to :root.
- ViewServlet stamps data-theme on the document element from UIThemeService#getActiveThemeId().
- ThemeCommands contributes one menu entry per theme and offers the active one as disabled, which is how the menu shows which theme is in effect.
Nothing anywhere reads prefers-color-scheme.
What it needs
A theme has to say which system preference it answers. This cannot be inferred: an application may configure three themes, so it must declare which of them stands for the system's light setting and which for its dark one. A UITheme property, with the plain case - one light, one dark - needing no more than one declaration each.
"No choice made" has to be distinguishable from "the default". getActiveThemeId() collapses the two: it answers the stored preference or, failing that, the default id. The service therefore needs to report the stored choice as absent, and ViewServlet needs to leave the attribute to the client in that case.
The menu needs a way back. A "System" entry that clears the stored preference, marked active - offered as disabled, the way the theme entries already are - while no choice is stored. Without it the feature is one-way: the first explicit pick is permanent.
Native controls do not follow the theme either
No theme declares the CSS color-scheme property, so everything the browser paints itself keeps its light appearance under the dark theme: scrollbars above all, and also form-control internals, the caret, spell-check underlines and the default canvas colour behind the page. A dark page with light-grey scroll bars down its side is the visible symptom.
color-scheme belongs to the theme rather than to a stylesheet, for the same reason the colours do: it is a statement about how that theme looks. Declared per theme and emitted into the theme's block beside the custom properties, it makes the browser's own furniture follow the theme.
Solution
The effective theme is always named by data-theme. Rather than leaving the attribute off and letting a prefers-color-scheme media block decide (which would force every rule selecting on data-theme, in applications as well as in the framework, to be written a second time under that media block, hard-coding which theme answers the system's dark preference), the page stamps the attribute on the client: while no choice is stored, a small inline script at the top of <head> sets data-theme from matchMedia('(prefers-color-scheme: dark)') before the first paint and keeps following the operating system while the page is open, marking the page with data-theme-mode="system" meanwhile. A stored choice is stamped by the server as before. Existing [data-theme="…"] rules and client code reading the attribute keep working unchanged in both modes, and there is one source of truth for the theme in effect. The script exposes window.tlTheme.select(id) and window.tlTheme.followSystem(), which the theme commands call.
Theme configuration. A UITheme declares its color-scheme (light or dark, inherited along extends, light at the root), emitted as a color-scheme declaration into its block. A theme marked system-default="true" is the one that answers the operating system's preference for its colour scheme; a second theme marked for the same scheme is a configuration error, and a scheme with no marked theme is answered by the default theme. The plain case therefore needs one declaration: the shipped dark theme carries color-scheme="dark" system-default="true".
Service and commands. UIThemeService.getSelectedThemeId() reports the stored choice, null when none is stored; setSelectedThemeId(null) clears it; getSystemTheme(ColorScheme) resolves the theme for a scheme and offersSystemThemes() tells whether light and dark resolve to different themes. ThemeCommands heads its entries with "System", which clears the choice and is offered as disabled while none is stored; the theme entries are disabled by the stored choice. The "System" entry appears only when offersSystemThemes() holds. SetThemeCommand without a theme switches to following the system.
Not in scope
The mapping of languages to flag icons in the same menu is a separate line of work.
Migration
- Behaviour: users of the React UI who have never picked a theme now see the theme answering their operating system's appearance preference instead of the configured default. Where that is unwanted, mark no theme as system-default: both preferences then resolve to the default theme, the page follows nothing, and the "System" entry does not appear.
- Theme configuration: every theme with a dark appearance should declare color-scheme="dark" (inherited by themes extending it), otherwise scrollbars and form controls keep their light appearance. The theme that should answer a dark operating system gets system-default="true"; a light theme other than the default theme that should answer a light operating system gets it too.
- API: UIThemeService.getActiveThemeId() and setActiveThemeId(String) are replaced by getSelectedThemeId() (returns null for "no choice stored", never the default) and setSelectedThemeId(String) (null clears). Code that needs the theme in effect on the client reads document.documentElement.dataset.theme.
- Nothing changes for application stylesheets: [data-theme="…"] rules keep applying in both modes.