-
Notifications
You must be signed in to change notification settings - Fork 15
Theming and 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.
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
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
- MobileβFriendly
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)