Development notes

Findings

Measured notes on theming a GNOME desktop.

Notes from building Vivid Gradience, written for anyone else theming a GNOME desktop. Each one cost real time to find, each was checked by measurement rather than inference, and none of them is obvious from the documentation. Where something turned out to be wrong, the wrong version is left in — the mistake is usually the useful part.

Back to Vivid Gradience Get Set Up — the first-run guide

What actually needs restarting

The short answer that the rest of this page is the long answer to.

"Log out and back in" is the standard advice for applying a theme, and on GNOME 50 it is wrong for almost everything. The surface people cannot reload by hand — the Shell — is the one that updates for free. What needs restarting is the individual application you happen to be looking at.

SurfaceTo see a changeWhy
Panel, overview, app grid, Super+Tab, quick settings, notifications, lock screen Nothing Main.setThemeStylesheet() restyles a running Shell
A GTK or libadwaita application Restart that application Named colours are read once, from the stylesheet loaded at startup
X11 titlebars on apps that draw no decorations Restart mutter-x11-frames Same startup-only rule; it is a plain GTK4 client
GDM (the login screen) Not covered here Separate stylesheet, owned by a different user

The one thing that genuinely cannot restyle itself in place is the application doing the theming. Everything else on this page follows from that asymmetry: a program can restyle the desktop around it far more readily than it can restyle its own window.

libadwaita ignores colour overrides applied at runtime

The one that changed a feature's design mid-build.

The plan for the live preview was a strip of real GTK widgets. The app already installs a CSS provider on every colour edit, so the widgets should have restyled for free. They did not — and neither does anything else in the process.

On libadwaita 1.9 / GTK 4.22, named-colour overrides are honoured only from the stylesheet loaded at startup. A provider added to the display afterwards has no effect on them. Each of these was tested by rendering the result and sampling the pixels:

The practical consequences: applying a theme is unaffected, because that path writes gtk.css and starts from there. But nothing can restyle itself in-process, so a live preview has to be drawn — or rendered by a separate process that starts with the generated stylesheet already in place. Vivid Gradience draws it.

GNOME Shell can be recoloured, and it applies instantly

Upstream dropped Shell support after 44. It was the method that aged, not the capability.

The old implementation vendored a copy of GNOME's Shell stylesheet sources for every release — 42, 43, 44, 45 — and compiled them with libsass. That caps at whatever version was vendored last, and every Shell release means porting a new SCSS tree. It is not a bug that it stopped at 44; it was always going to stop somewhere.

The alternative is to retheme the stylesheet the installed Shell already ships. Extract gnome-shell-dark.css from gnome-shell-theme.gresource, remap its greys onto the preset's surfaces by luminance, write the result out as a user theme. On GNOME Shell 50 that file is 3,325 lines containing 51 unique colours — all 51 remapped, clean parse, no warnings. Nothing in the process knows which Shell version it is running against, which is the point.

Worth stating plainly, because the two halves point in opposite directions: an application cannot restyle itself at runtime, but it can restyle the Shell at runtime. The constraint above is libadwaita's, not the desktop's.

X11 titlebars are reachable — just not through the colours named for them

And a warning about assuming which applications those are.

An X11 application that draws no decorations of its own gets a titlebar from mutter-x11-frames. Under a themed desktop it stays stock, which is exactly the seam you notice — a dark scheme everywhere and one application wearing a titlebar from somewhere else.

GTK4 still ships twelve named colours that look like the answer: wm_bg_a, wm_bg_b, wm_title, wm_border, wm_button_*. Overriding all twelve in gtk-4.0/gtk.css does nothing. They are vestigial. The helper calls gtk_window_set_titlebar(), so the titlebar is an ordinary GTK4 widget and wants an ordinary rule:

headerbar, .titlebar {
  background-image: none;
  background-color: @headerbar_bg_color;
  color: @headerbar_fg_color;
}

That recoloured a live titlebar on the first try. Two details explain why the named colours were the wrong instinct: mutter-x11-frames does not link libadwaita at all — it is a plain GTK4 client — and it runs with a bare environment, HOME and little else.

The warning is about scope, and it cost an hour to learn. The application that prompted this investigation — a mismatched titlebar on Spotify — turned out not to be an X11 client at all. It runs on Wayland and draws its own frame in Electron, so none of the above touches it. The list of windows actually decorated this way is short and shrinking, and it is worth checking _NET_CLIENT_LIST before assuming a given application is on it. The fix is real; the application it was reached for was not on the list.

Taken together with the Shell result, applying a theme has three tiers, and only one of them is expensive: the Shell updates instantly, X11 titlebars need one small helper restarted, and libadwaita applications need to be restarted themselves. The logout that used to be the standard advice is required for none of it.

Your theme and a Flatpak's theme are resolved separately

The one that silently splits a desktop in two.

A Flatpak cannot see /usr/share/themes. Flatpak reserves /usr, and a --filesystem=/usr/share/themes permission is refused outright. Sandboxed applications resolve GTK themes from org.gtk.Gtk3theme.<name> runtime extensions, mounted somewhere else entirely:

/usr/share/runtime/share/themes/<name>    <- the real mount point

So a theme installed by your distribution works for host applications and is invisible to every Flatpak. There is no error. The sandboxed apps quietly fall back to stock Adwaita, and the desktop splits along a line that has nothing to do with the theme and everything to do with how each application was packaged.

The wider point for anything that generates a theme: a generated gtk.css is applied on top of whatever base theme resolves underneath it. If the host and the sandbox disagree about that base, one scheme renders two ways. Colours are only ever as consistent as the substrate beneath them, and that substrate is worth checking before blaming the colours.

A theme cannot recolour GTK 3's built-in Adwaita

Why adw-gtk3 is not a preference.

Overriding named colours only works if the stylesheet underneath refers to those names. GTK 3's built-in Adwaita mostly does not — it is compiled, with its colours written directly into the rules:

Stylesheet@define-colorNamed referencesBaked literals
GTK 3 built-in Adwaita36—1,230
adw-gtk31251,189318

Redefining a colour cannot reach a literal, so on stock GTK 3 most overrides do nothing at all. adw-gtk3 is the inverse, and its variable names are the same ones libadwaita uses — window_bg_color, headerbar_bg_color, blue_1 through blue_5. It is not merely compatible; it is the substrate GTK 3 theming is written against.

Which makes it a dependency rather than a suggestion, and one that has to be satisfied twice — once on the host, once as a Gtk3theme extension for sandboxed applications. Vivid Gradience reports when either is missing rather than installing anything on your behalf.

Half the bundled schemes had unreadable labels

Now 85 schemes, 1190 foreground/background pairs, scored against WCAG. The audit below was the first pass, over the 76 bundled at the time.

The schemes written for this fork were checked as they were built. The ones inherited from upstream never had been. Auditing all of them found 36 with at least one pair below AA — white text on Nord's light green scored 1.77:1, and the whole Arc family sat at 1.92:1.

Almost every failure was a label on a coloured fill rather than body text: you could read those themes fine, but not the text on a success button. Every one of the 125 failing pairs could be brought to AA by changing only the label colour, so no fill was touched. Nord's green is still Nord's green.

One scheme is deliberately left failing. Solarized Light is the only case where ordinary reading text falls short, and its low contrast is the defining characteristic of the scheme rather than a mistake — the Solarized specification itself puts body text at 4.13:1 on the light background. Raising it would mean shipping something that is no longer Solarized.

Reading a palette out of a screenshot

Where the Casts schemes came from.

The Casts family was built from in-game palette screenshots — the only form the colours were available in. Picking 630 swatches by hand with an eyedropper was not realistic, so the tooling reads them directly.

Finding the grid is the interesting part, and the first version broke on the fifth palette. Gridlines cannot be located by absolute brightness: in a dark palette the cells are darker than the gridlines — one cast had cells at luminance ~50 against gridlines at ~28, while a light cast's cells sit at ~170. No fixed threshold separates those. What is invariant is that a separator is much darker than its immediate neighbours, and thin. So detection works on local contrast, treating narrow dark runs as gridlines and wide ones as the outer frame.

Where two near-black swatches meet there is no visible gridline at all, so bands measuring a clean multiple of the cell pitch get subdivided. With that, every cast resolved to the same layout.

A useful coincidence: those palettes are laid out five columns wide, and a preset's colour ramp is exactly five shades. Every row drops into a ramp with no rescaling — and libadwaita's surfaces are themselves an elevation ladder, so a single row can fill the window, header bar, sidebar and card in order.

Deriving a theme is not the same as designing one

What the generator gets right, and what it cannot know.

Turning a 90-colour palette into a scheme means assigning roles — surfaces, foreground, accent, status colours — and letting the other forty-odd variables follow. Three rules only became apparent from looking at rendered output:

The limit is intent. An algorithm optimising contrast reliably produces something readable and reliably misses something meant — a scheme named for decay is supposed to look muted, and a generator has no way to know that. Hence the palette editor on the roadmap: the machine proposes, and a person decides which reading of a palette was wanted.

Adwaita's folders are blue because the blue is in the file

Recolouring an icon set without forking it.

No colour variable reaches an icon. Adwaita's folders carry their blue as literal values inside the SVGs, which is why a fully themed desktop still opens a file manager full of stock-blue folders. The set that has to change is small — of roughly seventy full-colour icons, about twenty are blue at all, and the rest are greys that are meant to stay grey.

Two things about those blues were not what they looked like:

The harder problem is which colours to map onto. Choosing by name fails: a red scheme's red ramp against its own near-black file-manager background gives a contrast ratio of 1.22, which is a folder-shaped hole. Scoring for visibility against view_bg_color first, and only then for closeness to the accent, fixes it.

And score the output, not the input. A palette ramp derived from a source image can run cyan to purple across its own five shades, so the middle shade says very little about where the folder body will land — one scheme scored well on a ramp whose midpoint was periwinkle and rendered folders that were still blue. Measuring what the icon actually becomes is the only reliable test. Inheriting from Adwaita for everything untouched keeps this a twenty-file theme rather than a fork.

Firefox has two user stylesheets, and an about: URL will not tell you which

The one where 48 lines of CSS had never run, and I had already explained why with the wrong reason.

Vivid Gradience writes the preset into Firefox through firefox-gnome-theme. The browser window came out right — toolbar, tabs, menus, the Library, the profile manager. Next to it, in the same window: Settings, Add-ons and the print dialog in Firefox's stock palette, cyan accents and all. And a New Tab page in a grey that matched no preset.

Firefox has two user stylesheets. They are not two halves of one thing — they apply to different document types, and the address bar gives you no clue which is which:

FileApplies toWhich is
userChrome.css chrome documents the browser window, the Library, about:profilemanager, about:editprofile
userContent.css content documents about:newtab, about:preferences, about:addons, about:config — and every website

about:profilemanager is chrome. about:preferences is content. The engine had been writing only the chrome hook, for its whole existence — so the 48 --newtab-* declarations it generated for the new tab page had never applied once, on any profile, under any preset.

The wrong turn, kept in because it is the useful part. A previous session had already noticed the new tab ignoring presets, found that the profile had newtabWallpapers.wallpaper set to a built-in wallpaper — which does paint an image over the background — and concluded the block was being buried by it. Plausible, adjacent to the real code, and wrong. The wallpaper was set on one profile; the bug was on all of them. A real thing found near a bug is not the cause of the bug.

The answer had been sitting in the theme's own source the entire time. Upstream's userContent.css contains @import "theme/pages/newtab.css"; — they put their new-tab rules on the content side. Asking where does the project I am extending put this same kind of rule would have settled it in half a minute.

Why those pages were unthemed in the first place

Not a bug in Firefox, and not one in the theme — nobody was painting them. Firefox's in-content design tokens resolve to GTK system colours:

So the page asks GTK, and libadwaita answers with stock Adwaita no matter which preset is loaded — the same constraint the rest of this page keeps running into. Those pages were never ignoring the theme. The colour had no route in.

Two things make the fix cheap. The tokens sit inside a @layer, but cascade layers only order declarations within an origin — a user-origin declaration outranks any author-origin layer, so naming the surface is enough and no specificity games are needed. And most of the remaining token set is color-mix(in srgb, currentColor N%, transparent), so setting the canvas, the text colour and the accent carries buttons, borders, dividers and hover states along with it.

The trap in the fix

userContent.css applies to every website you visit. That splits what you may write there in two:

The palette also has to be restated in both files. The theme imports its own colour definitions from both entry sheets, so a content document that only ever saw customChrome.css was reading the theme's stock #222226. Overriding a variable in one origin does not reach a document served by the other.

If you are ever unsure which origin a surface belongs to, do not reason about it. Put :root { outline: 4px solid magenta !important; } in one file, restart, and look. Five minutes settles it permanently.

A theme add-on and a userChrome theme fight, and both lose

Why Vivid Gradience asks which Firefox profiles it may touch.

People give Firefox profiles different themes on purpose — it is how you tell which session a window belongs to when four are open. Apply a userChrome-based theme on top of that and you do not get one theme or the other. You get patches of both: purple menus above a grey toolbar.

The mechanism is ordinary cascade. A theme add-on sets its colours through --lwt-* variables in the author origin. userChrome.css is a user sheet, and a user-origin !important outranks an author-origin one — but only on the surfaces the userChrome theme actually names. Everything it does not name keeps the add-on's colour. Nothing is broken; two systems are each correctly winning half an argument.

The engineering answer is not to win the cascade harder. It is to not enter it. Vivid Gradience gives every Firefox profile its own switch and switches off, on sight, any profile whose extensions.activeThemeID is something other than one of the three neutral built-ins — the ones that only choose between light and dark and carry no colour of their own. Alpenglow is a real colour scheme and counts as the user's choice. A profile you themed deliberately is yours, and a preset should ask before walking over it.

Colour nuance does not survive other people's screens

The finding that changes which colour work is worth doing.

Schemes get authored on one display, by one pair of eyes, often a calibrated display and a trained pair. They get used on uncalibrated panels, wide-gamut panels showing sRGB raw, and with Night Light on. About one man in twelve has some red-green colour deficiency.

Running the bundled schemes through simulations of each shows a clear split. Numbers are mean ΔE across window, accent, view and header bar; below about 10 two schemes read as the same theme.

Scheme pairAs authoredDeuteranopiaNight LightWide gamut
Hatred / Agony31.929.323.836.5
Bluebell / Lilac Mist25.411.923.530.2
Rot / Conquest19.921.819.723.0
Cotton Candy / Powder Puff9.12.95.910.8

The pattern is consistent: separations built on lightness survive everything; separations built on hue or tint do not. Deuteranopia and Night Light both attack hue and leave lightness alone. Two pastels a few degrees apart can be obviously different to their author and identical to a good fraction of their audience.

The uncomfortable corollary is that a calibrated display and a practised eye make you less able to judge this, not more — you resolve distinctions that sit below the noise floor of an ordinary panel. So carry meaning in lightness and hue distance, treat fine saturation nuance as decoration, and be suspicious of any difference that disappears under simulation. If it only exists on the screen it was authored on, it is not a distinction.