Theming and Dark Mode - edmozley/freeitsm GitHub Wiki
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.
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:
-
Light mode stays pixel-identical. The
:root/[data-theme="default"]palette intheme.cssdefines each token to exactly the conventional FreeITSM colour. So converting a page to tokens changes nothing in light mode โvar(--surface, #fff)resolves to#ffffffeither way. -
Pages that don't load
theme.cssare safe. Around 140 pages shareinbox.cssbut never pull intheme.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.
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 intheme.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.
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 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_preferencesand the lookup is cached per request.
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.
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. -
modeis the only thing downstream code branches on. TinyMCE and Chart.js can't read CSS variables, so they key off the palette's declaredmode(lightordark) โ 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,--borderand--accentand 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.
Most of the UI themes itself from tokens. Two components render outside normal CSS and need explicit handling:
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 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.
-
Fallback must equal the original light colour. That's what keeps light mode identical and the non-
theme.csspages safe. Don't use a token whose light value differs noticeably from what the element had โ or accept the tiny shift knowingly. -
The
inbox.cssshared-stylesheet trap.inbox.cssis loaded by ~140 pages, most of which don't loadtheme.css. Never hardcode a dark colour in it โ alwaysvar(--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.cssbefore the module stylesheet, so token definitions are in scope.
require_once 'โฆ/includes/theme.php';- On the
<html>tag:data-theme="<?= Theme::active() ?>" data-theme-mode="<?= Theme::mode() ?>". - Add
<link rel="stylesheet" href="โฆ/theme.css?v=N">before the page's own stylesheet. - 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. - Bump the page stylesheet's cache-buster (
?v=N). - Check both modes; verify any
inbox.css-sharing page is unaffected (it will be, thanks to the fallbacks). - Log it in the changelog.
- Add a
[data-theme="<id>"] { โฆ }block totheme.cssoverriding the tokens that differ from:root. - Add an entry to
Theme::THEMESinincludes/theme.php(id => ['label' => 'โฆ', 'mode' => 'light'|'dark']). - 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.
- 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