Skip to content

Theming and Dark Mode

Ed Mozley edited this page Aug 6, 2026 · 2 revisions

Theming & Dark Mode

How FreeITSM's colour palettes (including Dark mode) work, and how to theme a new page or add a new palette. The whole system is built on CSS custom-property tokens and was designed to roll out gradually across ~30 module stylesheets and ~140 pages without ever breaking light mode.


The core idea: tokens with a light fallback

A palette is nothing more than a set of CSS variables. Module stylesheets reference tokens instead of hardcoded colours:

/* before */
.panel { background: #ffffff; color: #333333; border: 1px solid #e0e0e0; }

/* after */
.panel {
    background: var(--surface, #ffffff);
    color: var(--text, #333333);
    border: 1px solid var(--border, #e0e0e0);
}

The trick is in the fallback: it's always the original light-mode colour. That single decision buys two things:

  1. Light mode stays pixel-identical. The :root / [data-theme="default"] palette in theme.css defines each token to exactly the conventional FreeITSM colour. So converting a page to tokens changes nothing in light mode β€” var(--surface, #fff) resolves to #ffffff either way.
  2. Pages that don't load theme.css are safe. Around 140 pages share inbox.css but never pull in theme.css. On those pages the tokens are simply undefined, so the browser uses the fallback β€” the original colour. Nothing to coordinate, nothing to break.

So tokenising a stylesheet is a non-breaking change everywhere, and dark mode (or any future palette) "just works" the moment a page opts in.


How a palette is applied

The active palette is set server-side on the <html> element, so there's no flash of the wrong theme on load:

<html data-theme="dark" data-theme-mode="dark">
  • data-theme β€” the palette id (default, dark, …). Selects the token block in theme.css.
  • data-theme-mode β€” the palette's light/dark mode, declared in the registry. Used by things that can't read CSS tokens (TinyMCE, Chart.js β€” see below).

theme.css declares each palette as a selector block:

:root,
[data-theme="default"] { /* the canonical light look β€” every token = original colour */ }

[data-theme="dark"]     { /* only the tokens that differ in dark */ }

Tokens a palette doesn't override inherit from :root, so a palette only needs to declare what actually changes.


The token vocabulary

Defined in assets/css/theme.css. The main groups:

Group Tokens
Surfaces --app-bg, --surface, --surface-2, --surface-3, --surface-hover, --row-unread
Text --text, --text-muted, --text-dim, --text-faint
Borders --border, --border-soft
Accent --accent, --accent-hover, --accent-soft, --on-accent
Semantic pairs --success-*, --danger-*, --warning-* (bg / text / accent or border)
Module families --problem-* (purple), --channel-* (messaging green)
Misc --shadow

When tokenising a page, reach for the closest existing token rather than inventing one. The fallback should always be the colour the element had before.


The registry & resolution

The palette list and the active-palette logic live in includes/theme.php:

const THEMES = [
    'default' => ['label' => 'Light', 'mode' => 'light'],
    'dark'    => ['label' => 'Dark',  'mode' => 'dark'],
];
  • Theme::active($module = null) returns the active palette id; Theme::mode($module = null) returns its light/dark mode.
  • Resolution order: a per-module preference (theme_<module>) β†’ the global preference (theme) β†’ the default (default). So an analyst can run one palette everywhere, or a different palette per module.
  • Preferences are stored per-analyst in user_preferences and the lookup is cached per request.

The picker

Rendered in the account menu (top-right, in includes/waffle-menu.php) as a row of swatches. Selecting one calls setTheme(id), which POSTs to api/system/set_user_preference.php (key theme) and then reloads the page so the server re-renders with the new data-theme. Simple, flash-free, and stateless on the client.


Scaling to many palettes

Nothing in the design is hardwired to "light vs dark" β€” that's just the first two palettes. The architecture scales to any number of named palettes, and adding one is a content change, not an engineering one:

A new palette = one token block + one registry line + one swatch colour. No page edits, no JS, no per-module work.

Because every tokenised page reads the same variable names, the moment you add [data-theme="solarized"] (or sepia, high-contrast, nord, midnight-blue, a customer's brand colours, even a tongue-in-cheek miami-techno) to theme.css, the entire already-tokenised UI adopts it for free β€” tickets, dashboard, calendar, rota, CSAT, the lot. The palette only declares the tokens that differ from :root; everything else inherits.

What makes this hold up as the list grows:

  • One contract, many implementations. Pages depend on the token vocabulary (--surface, --text, --accent, the semantic pairs…), never on a specific palette. A palette is just one concrete set of values for that contract. Add the 3rd, 8th, 20th palette and no page needs to know.
  • mode is the only thing downstream code branches on. TinyMCE and Chart.js can't read CSS variables, so they key off the palette's declared mode (light or dark) β€” not its id. So a brand-new palette works with those components automatically, as long as it says whether it's fundamentally a light or a dark palette. A vivid "Miami Techno" palette that's dark-ish just sets 'mode' => 'dark' and TinyMCE/Chart.js fall into line.
  • Per-module selection comes for free. Resolution is theme_<module> β†’ theme β†’ default, so with N palettes an analyst can run Dark on Tickets, Sepia on Knowledge, and the default everywhere else β€” no extra plumbing per palette.
  • Partial palettes are fine. A palette doesn't have to redefine everything. A "high-contrast" palette might only bump --text, --border and --accent and inherit the rest; a brand palette might only swap --accent*. Define what differs, inherit the rest from :root.

The practical ceiling isn't the architecture β€” it's just taste and the picker UI. Want a seasonal palette, a customer-branded skin, or an accessibility high-contrast mode? Each is the same three-step drop-in described in Add a new palette below.

The two things CSS tokens can't reach

Most of the UI themes itself from tokens. Two components render outside normal CSS and need explicit handling:

TinyMCE (the reply editor)

TinyMCE renders inside an iframe, so page CSS can't restyle it. Instead we swap its bundled skin based on the palette mode (initTinyMCE() in inbox.js):

const isDark = (document.documentElement.getAttribute('data-theme-mode') || 'light') === 'dark';
tinymce.init({
    skin:        isDark ? 'oxide-dark' : 'oxide',
    content_css: isDark ? 'dark' : 'default',
    // …
});

Because it keys off data-theme-mode (not a specific id), any new palette works with no change here β€” it just needs to declare its mode in the registry.

Chart.js (dashboard widgets)

Chart.js draws on a <canvas>, so it can't read CSS variables. Its global text and gridline colours are set from the active palette mode so charts stay readable in dark mode. The vivid data-series colours are left untouched β€” they read on both light and dark.


Gotchas & conventions

  • Fallback must equal the original light colour. That's what keeps light mode identical and the non-theme.css pages safe. Don't use a token whose light value differs noticeably from what the element had β€” or accept the tiny shift knowingly.
  • The inbox.css shared-stylesheet trap. inbox.css is loaded by ~140 pages, most of which don't load theme.css. Never hardcode a dark colour in it β€” always var(--token, #light) so the fallback protects every page that isn't themed yet.
  • Leave self-coloured badges alone. Priority colours, the on-call chip, location badges, CSAT distribution bars, chart series β€” these carry their own background and read fine on both light and dark surfaces. Tokenising them would muddy the data encoding for no benefit.
  • Semantic pairs flip together. Status pills use --success-bg + --success-text (etc.) so they become dark-bg + light-text in dark mode rather than staying pale.
  • Load order: theme.css before the module stylesheet, so token definitions are in scope.

Checklist: theme a new page

  1. require_once '…/includes/theme.php';
  2. On the <html> tag: data-theme="<?= Theme::active() ?>" data-theme-mode="<?= Theme::mode() ?>".
  3. Add <link rel="stylesheet" href="…/theme.css?v=N"> before the page's own stylesheet.
  4. In the page's CSS, replace hardcoded colours with var(--token, #original) β€” surfaces, text, borders, accent first; semantic pairs for pills; leave vivid/data colours alone.
  5. Bump the page stylesheet's cache-buster (?v=N).
  6. Check both modes; verify any inbox.css-sharing page is unaffected (it will be, thanks to the fallbacks).
  7. Log it in the changelog.

Checklist: add a new palette

  1. Add a [data-theme="<id>"] { … } block to theme.css overriding the tokens that differ from :root.
  2. Add an entry to Theme::THEMES in includes/theme.php (id => ['label' => '…', 'mode' => 'light'|'dark']).
  3. Add a swatch colour for it in the picker styles (waffle-menu.php).

That's it β€” every already-tokenised page picks up the new palette automatically, and TinyMCE/Chart.js follow its declared mode.


Related pages

  • Help page house style β€” the worked example of this design taken to its conclusion: all 26 in-app guides draw their entire look from four accent tokens set once per page
  • Architecture β€” where the front-end pieces fit
  • Tickets β€” the most heavily-themed module

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally