Files
aiturk-mobil/app/src/main/java/com/m57/hermescontrol/theme/THEMES.md
T
728daa9acd release: promote dev to main for v2.0.0 (#1426)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Lx-204 <15377891490@163.com>
Co-authored-by: AlexDev_ <56083016+alexdev03@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Nikhil Nair <32665919+nikhilnair31@users.noreply.github.com>
Co-authored-by: marciodps <95321123+marciodps@users.noreply.github.com>
Co-authored-by: ThomasCrund <39323104+ThomasCrund@users.noreply.github.com>
2026-10-01 11:19:09 +03:00

8.1 KiB
Raw Blame History

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 — mode fallback + dynamic colors
├── ThemeRegistry.kt               # single preset/palette registry
├── 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
    └── GarnetScheme.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. surfaceTint comes from primary. Every other Material color role is required explicitly, including bright/dim surfaces and all fixed accent roles. No role may inherit Material baseline colors. Fixed accent and foreground roles stay identical across a preset’s shipped modes; Fixed/Dim may share an official swatch when the source palette has no accessible tonal ladder.

Prefer upstream swatches. Document every derived color beside its declaration with the official pairing’s measured contrast and why a derived role is needed.

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.
  • Normal-text foreground/background pairs, including accent containers, fixed accents, semantic status fills, errors, and the surface ladder, are enforced at >= 4.5:1 contrast by ThemePaletteTest. The separate full-bleed header guard remains >= 3:1.

Reusable StatusBadge and SessionLiveStatusIndicator use a shared statusBadgeColors mapping: status fills (success, warning, error, info) with their matching on* text/icons. onSuccess is not a foreground token for successContainer. Palette tests exercise this exact renderer mapping in every resolved mode, including fallback modes; neutral badges use Material tokens.

Enabled Material components also have a component-state contract:

  • Graphics/boundaries target >= 3:1: switch thumbs/tracks and borders, radio and checkbox states, slider active/inactive tracks, text-field borders, and segmented borders.
  • Primary/error text and field labels on surface/background target >= 4.5:1.
  • Segmented content uses the corresponding container foreground at >= 4.5:1.

ThemeComponentContrastTest checks the pinned Material role combinations in every resolved preset/mode. ThemeComponentGalleryTest reads the installed Compose public color defaults and verifies rendered pixels for all fourteen preset/mode combinations, including focused/error fields. Disabled controls are deliberately excluded from the enabled contrast contract; Material uses opacity to mute them. Settings switches use Material defaults (primary track / onPrimary thumb), not the reversed primaryContainer track / primary thumb combination.

Outlines must contrast against both surface and the unchecked switch track (surfaceContainerHighest). Named palettes may need documented derived primary or error tones for direct text; fixed roles still preserve their upstream accents.

Garnet

Garnet preserves Default's slate surface ladder, typography, and semantic status colors. Its seed is #990000, used as the light primary and dark primary container. Dark primary/text/icons use a lighter #FFB4AB for contrast; rose secondary and copper tertiary roles, their containers, fixed accents, inverse primary, and surface tint follow the new accent family. Both modes are shipped.

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

ThemeRegistry.kt maps every ThemePreset to one ThemePalette. Resolution, UI selection, and palette tests use this registry. An exact enum/registry coverage test rejects missing, duplicate, or reordered entries. Android string resources stay in the UI’s exhaustive label mapping, outside the palette model.

Theme.kt reads preset.palette() and applies the mode fallback above.

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. The preset selector stays enabled and explains that it controls status colors while Material You supplies app colors.

Adding a new theme

  1. Create presets/<Name>Scheme.kt as a template fill (see above) — buildTheme for a full theme, buildThemeDarkOnly / buildThemeLightOnly for single-mode themes. Every status color must be bespoke — no aliasing.
  2. Register the new palette in ThemeRegistry.kt.
  3. Add the enum entry MY_THEME to ThemePreset in Theme.kt.
  4. Wire the UI: add the exhaustive label mapping in ui/settings/components/AppearanceSection.kt (and any string resource).
  5. Verify:
    ./gradlew ktlintCheck testDebugUnitTest
    
    ThemePaletteTest checks text contrast, complete Material slot mapping, mode-independent fixed roles, registry coverage, and mode/fallback invariants.

Conventions

  • ktlint 1.8.0 is enforced in CI. Run ./gradlew ktlintCheck before committing (the gradle task is the gate, not the standalone ktlint binary).
  • Import order is ASCII-lexicographic (uppercase before lowercase).
  • Every change goes through a PR targeting dev — never push directly to main or dev.