5.4 KiB
Themes
How theming works in Hermes Mobile, and how to add or edit a theme.
This is the implementation guide. DESIGN.md defines visual and interaction requirements; the Kotlin sources define the actual tokens. When changing tokens or behavior, update the relevant documentation too.
Where themes live
All theme code is under:
app/src/main/java/com/m57/hermescontrol/theme/
├── Theme.kt # dispatcher — preset lookup + mode fallback
├── PaletteTemplate.kt # the template — the ONE shape every theme follows
├── Color.kt # shared tokens for non-theme code (status fallbacks, code blocks, …)
├── HermesStatusColors.kt # semantic status color model (success/warning/error/info + on*)
├── Type.kt / Shapes.kt # typography + shape tokens
├── Spacing.kt / Motion.kt # spacing + motion tokens
└── presets/ # one file per ThemePreset — same skeleton everywhere
├── DefaultScheme.kt
├── MonochromeScheme.kt
├── GruvboxScheme.kt
├── CatppuccinScheme.kt
├── AmoledScheme.kt # dark-only (ThemeMode.DARK_ONLY)
└── NordScheme.kt
The template — one shape for every theme
Every preset file is a single ThemePalette built from raw color specs via
the template in PaletteTemplate.kt. A theme is a pure color spec: fill in
PaletteColors (the Material slots) + the semantic status set
(HermesStatusColors) and the app does the rest. No per-preset behavior, no
aliases, no shared-token imports.
val MyTheme =
buildTheme(
dark =
PaletteColors(
primary = Color(0xFF…),
// …every slot…
status =
HermesStatusColors(
success = Color(0xFF…),
// …
),
),
light = PaletteColors(/* … */),
)
The template maps PaletteColors into the Material 3 ColorScheme slots. The
Material error slots (error, onError, errorContainer, onErrorContainer)
are derived from the theme's status set — the theme author defines semantic
colors once.
Preset conventions
- Named swatches for any hex reused across slots (Gruvbox bg ladder, Catppuccin's official names, Nord's nord0–nord15, Monochrome's mono ramp). A theme with repeated hexes should define them once at the top of the file.
- Bright/faded accent split (Gruvbox, Slate): bright variants against dark surfaces, muted "faded" variants against light surfaces so contrast holds without neon saturation. Single-accent-set themes (Nord) instead keep dark "on" text in both modes — pastel accents stay light, so Snow-Storm-style light text would fail contrast.
- Grayscale status colors (Monochrome, AMOLED) separate success/warning/ error/info by lightness only — consumers must pair with icons or labels.
- Every shipped mode's error slot pairs are enforced at >= 3:1 contrast by
ThemePaletteTest— a preset that breaks this fails the unit-test gate.
Theme modes
A theme declares which modes it ships via the factory you build it with:
| Factory | Mode | Meaning |
|---|---|---|
buildTheme(...) |
FULL |
bespoke dark + light palettes |
buildThemeDarkOnly(...) |
DARK_ONLY |
dark only; light mode falls back to the default theme (AMOLED) |
buildThemeLightOnly(...) |
LIGHT_ONLY |
light only; dark mode falls back to the default theme |
The dispatcher falls back to the brand default theme for any mode a preset doesn't ship — never to a sibling preset.
The dispatcher
Theme.kt is a pure lookup — no special-casing. Given a ThemePreset and
a dark flag, themeFor() returns the preset's ThemePalette, then the scheme /
status are read off it (with the mode fallback above):
private fun themeFor(preset: ThemePreset): ThemePalette = when (preset) {
ThemePreset.DEFAULT -> DefaultTheme
ThemePreset.MONOCHROME -> MonochromeTheme
// …one line per preset…
}
Dynamic (Material You) color on API 31+ can optionally override the preset scheme when
useDynamicColors = true (defaults to false). Semantic status colors are always resolved from the
active preset via LocalHermesStatusColors.
Adding a new theme
- Create
presets/<Name>Scheme.ktas a template fill (see above) —buildThemefor a full theme,buildThemeDarkOnly/buildThemeLightOnlyfor single-mode themes. Every status color must be bespoke — no aliasing. - Add 1 line to
themeFor()inTheme.kt+ import the new val. - Add the enum entry
MY_THEMEtoThemePresetinTheme.kt. - Wire the UI: add the label + selection in
ui/settings/components/AppearanceSection.kt(and any string resource). - Verify:
./gradlew ktlintCheck testDebugUnitTestThemePaletteTestasserts >= 3:1 contrast on every shipped mode's error slot pairs and the ThemeMode invariants — it must stay green.
Conventions
- ktlint 1.8.0 is enforced in CI. Run
./gradlew ktlintCheckbefore committing (the gradle task is the gate, not the standalonektlintbinary). - Import order is ASCII-lexicographic (uppercase before lowercase).
- Every change goes through a PR targeting
dev— never push directly tomainordev.