Skip to content

Theme System

This guide documents how dark/light theme switching is implemented in Waldur HomePort.

Architecture Overview

The theme is one attribute: data-theme="light|dark" on <html>. Everything follows it:

  • Design tokens. Every semantic token file has a :root[data-theme='dark'] block, so var(--bg-primary) and friends switch.
  • Tailwind. The dark: variant keys off the same attribute.
  • Bootstrap / Metronic. src/metronic/sass/style.scss is one stylesheet for both themes: its colours read the tokens, and the few pairings no token has use the dark-theme mixin (core/base/mixins/_theme.scss), which emits :where([data-theme='dark']) & — the same specificity as the rule it overrides:
1
2
3
4
5
6
7
.list-group-item {
  background-color: var(--bg-primary);

  @include dark-theme {
    background-color: var(--bg-tertiary);
  }
}

There used to be a second, dark compile (style.dark.scss, $mode: dark, with the SCSS ramps inverted by isDarkMode()), swapped in when the theme changed. It is gone; don't reintroduce a theme-specific SCSS value. A colour inside an SVG data URI, which a var() cannot reach, is a function of its colour (_svg-icons.scss) with a dark override in _dark-icons.scss; css-color(gray-dark, 500) gives the CSS ramp value as Sass.

Key Files

File Purpose
src/theme/ThemeProvider.tsx React context provider for theme state
src/theme/useTheme.ts Hook to access current theme
src/theme/ThemeStorage.ts localStorage persistence
src/theme/utils.ts Injects the Metronic stylesheet once, sets data-theme, initial theme detection
src/theme/ThemeSwitcher.tsx UI toggle component
src/metronic/sass/_tokens.scss Ring and focus-ring variables
src/metronic/sass/_colors.scss Derived color variables ($success, $text-muted, brand ramp, …)
src/metronic/sass/_color-ramps.scss Theme-aware color ramps — generated, edit packages/design-tokens/tokens/colors.json (see design-tokens.md)
src/metronic/sass/core/base/mixins/_theme.scss dark-theme / light-theme mixins, mirrored-ramp-color()

Theme Initialization Flow

  1. Application.tsx wraps the app with ThemeProvider
  2. ThemeProvider calls getInitialTheme() which checks:
  3. localStorage for saved preference
  4. OS prefers-color-scheme setting
  5. Falls back to 'light'
  6. loadTheme() injects the Metronic stylesheet (once) and sets data-theme on <html>, which switches the CSS custom properties, Tailwind's dark: variant and the Metronic dark-theme rules
  7. User toggles update localStorage and data-theme; nothing is reloaded

Usage Patterns

Use the semantic utilities. Each one is exactly the design token of the same name, so it follows the theme without a dark: twin:

1
2
3
4
5
6
7
8
// Good - semantic utilities, theme-aware
<div className="bg-secondary rounded-md border-[1px] border-secondary p-4">
  <h6 className="text-primary">Title</h6>
  <p className="text-quaternary">Description</p>
</div>

// Avoid - Bootstrap Alert components don't always adapt text colors properly
<Alert variant="info">This may have contrast issues in dark mode</Alert>

Theme-aware utilities (full reference in design-tokens.md):

Utility Token Light → Dark
bg-primary --bg-primary White → near-black
bg-secondary --bg-secondary gray-50 → dark gray
bg-tertiary / bg-quaternary --bg-tertiary / --bg-quaternary gray-100 / 200 → darker grays
text-primary … text-senary --text-primary … --text-senary gray-900 … 300 → light grays
border-primary --border-primary Control border
border-secondary --border-secondary Card border
text-brand --brand-color Brand (tenant colour)

For the brand colour use text-brand (and border-brand, outline-brand). State colours (success, warning, danger) use the status matrix: bg-warning-subtle text-warning.

Bootstrap emits no colour utilities: its old class names (text-muted, text-success, bg-light, …) paint nothing, and every colour class is Tailwind's (text-muted → text-quaternary). Never pair Bootstrap's border class with a Tailwind border colour: the colour never paints (use border-[1px]). See design-tokens.md § Bootstrap colour utilities.

Pattern 2: useTheme() Hook

For third-party libraries that need explicit theme values:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import { useTheme } from '@/theme/useTheme';

const MyComponent = () => {
  const { theme } = useTheme();

  // For Monaco Editor
  const editorTheme = theme === 'dark' ? 'vs-dark' : 'vs-light';

  // For ECharts
  const chartTheme = `${theme}-metronic`;

  return <MonacoEditor theme={editorTheme} />;
};

Pattern 3: CSS Custom Properties in SCSS & CSS

When writing custom SCSS or CSS, use semantic CSS custom properties directly:

1
2
3
4
5
.my-component {
  background-color: var(--bg-primary); // Adapts automatically
  color: var(--text-primary); // Adapts automatically
  border: 1px solid var(--border-primary); // Adapts automatically
}

Common semantic tokens (defined in packages/design-tokens/src/css/semantic/; the full list with values is in design-tokens.md § Semantic roles):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Backgrounds
var(--bg-primary)        // Base surface
var(--bg-primary-hover)  // Hover/highlight on that surface
var(--bg-secondary)      // Faint panel
var(--bg-tertiary)       // Tiles, chips
var(--bg-quaternary)     // Stronger fill

// Text and icons: one neutral scale, strongest first
var(--text-primary)      // Headings, primary content
var(--text-secondary)    // Body copy
var(--text-tertiary)     // Supporting text
var(--text-quaternary)   // Muted text, placeholders
var(--text-quinary)      // Faint text, decorative icons
var(--text-senary)       // Disabled marks
var(--text-disabled)     // Disabled input text

// Borders
var(--border-primary)    // Control borders
var(--border-secondary)  // Card borders
var(--border-divider)    // A drawn line

// States: --status-{success,warning,danger}-{bg,border,solid,fg}
var(--status-danger-fg)  // Error text/icon, AA on any surface
var(--status-warning-bg) // Warning tint

// Brand (runtime, per tenant): never $primary in SCSS
var(--bg-brand-solid)
var(--border-brand-solid)
var(--fg-brand-primary)

Pattern 4: CSS Custom Properties

For runtime theming needs, use CSS custom properties:

1
2
3
4
5
6
7
8
9
/* Defined in packages/design-tokens/src/css/semantic/background.css */
:root {
  --bg-primary: #ffffff;
}

/* Usage */
.component {
  background: var(--bg-primary);
}

Common Pitfalls

Static Brand Colour in SCSS

$primary and $primary-N compile to a static default green; the tenant's brand is the runtime --brand-* ramp. Paint brand in SCSS with var(--bg-brand-solid), var(--fg-brand-primary), var(--brand-600) and so on, never $primary. See design-tokens.md § The brand colour in SCSS.

Hardcoded Colors

Avoid hardcoding colors that won't adapt:

1
2
3
4
5
// Bad - won't adapt to dark mode
<div style={{ backgroundColor: '#f5f5f5', color: '#333' }}>

// Good - semantic utilities follow the theme
<div className="bg-secondary text-secondary">

Third-Party Components

Components like react-select, Monaco Editor, and ECharts need explicit theme configuration via the useTheme() hook. Check existing implementations for examples:

  • src/form/select/theme.ts - react-select theming
  • src/form/MonacoField.tsx - Monaco Editor theming
  • src/core/EChart.tsx - ECharts theming

Testing Dark Mode

  1. Toggle theme using the switcher in the user dropdown menu
  2. Check that:

  3. Text is readable against backgrounds

  4. Borders are visible but not harsh
  5. Interactive elements have visible hover states
  6. Third-party components match the overall theme