theme(849): unify presets behind one palette template + bespoke AMOLED status colors (#852)
* refactor(theme): unify all presets behind one palette template * docs(theme): update THEMES.md and AGENTS.md for the palette template * fix(theme): restore per-preset error slot contrast, merge StatusColors into HermesStatusColors - onErrorContainer becomes an explicit per-preset field (was derived from onError — same-color pairs in Gruvbox/Catppuccin light made error text invisible; 6 screens render this slot) - Default dark + AMOLED onError -> White (were <3:1 on their error color) - drop StatusColors + statusColorsOf mapper; PaletteColors.status is typed HermesStatusColors directly (identical field lists, pure copy ceremony) - ThemePalette getters now by lazy; schemeFor/statusFor accessors; requireNotNull on the default-theme fallback (DefaultTheme is FULL) - resolvers internal for testability * test(theme): contrast, mode-validation, and fallback contract tests - every shipped mode's onError/error + onErrorContainer/errorContainer pair meets >= 3:1 WCAG contrast (kills the error-slot regression class) - ThemeMode init validation rejects illegal palette combinations - AMOLED darkOnly contract + light-mode fallback to Default light * refactor(theme): Catppuccin — named swatches from the official palette spec Hex values verified identical (zero color drift): repeated colors (Crust, Surface0/1, Base, Text...) now live once as named swatches matching catppuccin.com; bespoke light primary container tints stay local. Mocha/Latte pair unchanged. * refactor(theme): Gruvbox — authentic bright/faded accent scheme with named swatches Redesign per gruvbox spec: bright accents on dark bg0-bg4, faded accents on light bg0-bg4 (correct contrast play for light mode). Named swatches, single Gray shared across modes. Dark error pair fixed for >= 3:1 (onErrorContainer -> DarkFg1 cream, was bright red on Bg3 = 1.87:1). * feat(theme): replace default preset with Slate — modern slate blues + teal accents The brand default (voltage purple/amber) becomes Slate: bright teal/blue accents on dark bg0-bg4, faded variants on light bg0-bg4. Named swatches, single neutral gray shared across modes. Slate is also the fallback for darkOnly/lightOnly presets (AMOLED light mode). * feat(theme): add Nord preset — Polar Night/Snow Storm surfaces, Frost/Aurora accents Nord's single accent set is reused across both modes (pastel accents stay light, so on* colors use Polar Night dark text in both modes — documented in-file). Surface ladder shares outer tiers (Nord ships 4 dark + 3 light tones for 5 tiers). Dark error pair fixed for >= 3:1 (onErrorContainer -> Nord6, was Nord11 on Nord2 = 2.0:1). Wired into ThemePreset, the settings picker, and docs; default label now reads Default (Slate). * refactor(theme): Monochrome + AMOLED — pure grayscale ramp, true-black AMOLED variant Monochrome: every swatch R=G=B, roles by lightness only (status colors included — documented trade-off: pair with icons/labels). AMOLED: dark-only true-black variant (pure #000000, OLED pixels off; #121212 baseline of Monochrome doesn't do that), containers collapse where nothing darker exists. Both ship maximum-contrast grayscale pairs (12.8:1+ on error slots). * chore(theme): drop Neon Noir, finalize docs + picker label - remove NeonNoirScheme.kt + ThemePreset.NEON_NOIR everywhere (dispatcher, picker, strings EN/KO, tests, AGENTS.md, THEMES.md) — 6 presets remain: Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Nord - picker label for the default preset is now plain 'Default' - THEMES.md: preset conventions section (named swatches, bright/faded split, grayscale-status caveat, contrast test gate) + verify step runs testDebugUnitTest * fix(theme): make drawer icon colors visibly follow the active preset Selected icon now tints with the theme's primary accent (teal for Slate, orange for Gruvbox, mauve for Catppuccin, frost-blue for Nord) instead of onPrimaryContainer — the new presets' containers are all dark gray with near-white text, so the selected row looked identical across themes. Unselected icon/text colors pinned explicitly to onSurfaceVariant. * chore(ui): remove lightning emoji from the app - drawer title + About name: '⚡ Hermes' -> 'Hermes' (EN + KO) - skill markers (chat self-improvement badge, memory graph): ⚡ -> 🛠 - in-progress indicators (subagent inspection): ⚡ -> 🔄 --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
52e33a96b7
commit
ab42e19b7f
21 files changed
+1312
-862
No files matched your search
@@ -8,106 +8,116 @@ All theme code is under:
|
||||
|
||||
```
|
||||
app/src/main/java/com/m57/hermescontrol/theme/
|
||||
├── Theme.kt # dispatcher — resolves scheme + status colors per preset
|
||||
├── Color.kt # brand design tokens (DarkSurface, HermesPurple, StatusGreen, …)
|
||||
├── 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
|
||||
└── presets/ # one file per ThemePreset — same skeleton everywhere
|
||||
├── DefaultScheme.kt
|
||||
├── MonochromeScheme.kt
|
||||
├── GruvboxScheme.kt
|
||||
├── CatppuccinScheme.kt
|
||||
├── AmoledScheme.kt
|
||||
└── NeonNoirScheme.kt
|
||||
├── AmoledScheme.kt # dark-only (ThemeMode.DARK_ONLY)
|
||||
└── NordScheme.kt
|
||||
```
|
||||
|
||||
## The 4-token shape
|
||||
## The template — one shape for every theme
|
||||
|
||||
Every file in `presets/` is uniform — it exports the same four tokens:
|
||||
|
||||
| Token | Type | Purpose |
|
||||
|-------|------|---------|
|
||||
| `XxxDarkColorScheme` | `ColorScheme` | dark-mode Material 3 scheme |
|
||||
| `XxxLightColorScheme` | `ColorScheme` | light-mode Material 3 scheme |
|
||||
| `XxxDarkStatusColors` | `HermesStatusColors` | dark semantic status colors |
|
||||
| `XxxLightStatusColors` | `HermesStatusColors` | light semantic status colors |
|
||||
|
||||
A preset with no bespoke status set (AMOLED) aliases the defaults:
|
||||
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.
|
||||
|
||||
```kotlin
|
||||
val AmoledDarkStatusColors = DefaultDarkStatusColors
|
||||
val AmoledLightStatusColors = DefaultLightStatusColors
|
||||
val MyTheme =
|
||||
buildTheme(
|
||||
dark =
|
||||
PaletteColors(
|
||||
primary = Color(0xFF…),
|
||||
// …every slot…
|
||||
status =
|
||||
HermesStatusColors(
|
||||
success = Color(0xFF…),
|
||||
// …
|
||||
),
|
||||
),
|
||||
light = PaletteColors(/* … */),
|
||||
)
|
||||
```
|
||||
|
||||
AMOLED has no bespoke light scheme, so it aliases the default light scheme too:
|
||||
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.
|
||||
|
||||
```kotlin
|
||||
val AmoledLightColorScheme = DefaultLightColorScheme
|
||||
```
|
||||
## 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, it returns the matching scheme/status from the preset file:
|
||||
`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):
|
||||
|
||||
```kotlin
|
||||
private fun resolveColorScheme(preset: ThemePreset, darkTheme: Boolean) = when (preset) {
|
||||
ThemePreset.DEFAULT -> if (darkTheme) DefaultDarkColorScheme else DefaultLightColorScheme
|
||||
ThemePreset.MONOCHROME -> if (darkTheme) MonochromeDarkColorScheme else MonochromeLightColorScheme
|
||||
// …one line per preset…
|
||||
}
|
||||
|
||||
private fun resolveStatusColors(preset: ThemePreset, darkTheme: Boolean) = when (preset) {
|
||||
ThemePreset.DEFAULT -> if (darkTheme) DefaultDarkStatusColors else DefaultLightStatusColors
|
||||
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+ overrides the preset scheme when
|
||||
`useDynamicColors = true`.
|
||||
|
||||
## DEFAULT uses design tokens — that's intentional
|
||||
|
||||
`DefaultScheme.kt` imports ~47 named tokens from `Color.kt`
|
||||
(`DarkBackground`, `HermesPurple`, `StatusGreenContainer`, …) and builds its
|
||||
schemes from them. This is **by design**: DEFAULT is the brand reference theme,
|
||||
so a palette change in `Color.kt` flows straight through.
|
||||
|
||||
The other presets are hand-authored community palettes and use **raw hex**
|
||||
(`Color(0xFFFE8019)`), so they only import `Color` (+ `HermesStatusColors`).
|
||||
Do **not** "fix" DEFAULT to raw hex to match them — that would break the token
|
||||
linkage. The asymmetry is semantic, not sloppy.
|
||||
`useDynamicColors = true`. Semantic status colors are always resolved from the
|
||||
active preset via `LocalHermesStatusColors`.
|
||||
|
||||
## Adding a new theme
|
||||
|
||||
1. **Create** `presets/<Name>Scheme.kt` exporting the 4 tokens:
|
||||
```kotlin
|
||||
package com.m57.hermescontrol.theme.presets
|
||||
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.m57.hermescontrol.theme.HermesStatusColors
|
||||
|
||||
val MyThemeDarkColorScheme = darkColorScheme(/* … */)
|
||||
val MyThemeLightColorScheme = lightColorScheme(/* … */)
|
||||
val MyThemeDarkStatusColors = HermesStatusColors(/* … */)
|
||||
val MyThemeLightStatusColors = HermesStatusColors(/* … */)
|
||||
```
|
||||
2. **Add 2 lines** to each `when` in `Theme.kt` (`resolveColorScheme` +
|
||||
`resolveStatusColors`), importing the new tokens at the top of the file.
|
||||
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. **Add 1 line** to `themeFor()` in `Theme.kt` + import the new val.
|
||||
3. **Add the enum entry** `MY_THEME` to `ThemePreset` in `Theme.kt`.
|
||||
4. **Wire the UI**: add the label + selection in
|
||||
`ui/settings/components/AppearanceSection.kt` (and any string resource).
|
||||
5. **Verify**:
|
||||
```bash
|
||||
./ktlint --format app/src/main/java/com/m57/hermescontrol/theme/presets/<Name>Scheme.kt
|
||||
./gradlew compileDebugKotlin
|
||||
./gradlew ktlintCheck testDebugUnitTest
|
||||
```
|
||||
`ThemePaletteTest` asserts >= 3:1 contrast on every shipped mode's error
|
||||
slot pairs and the ThemeMode invariants — it must stay green.
|
||||
|
||||
## Conventions
|
||||
|
||||
- ktlint 1.2.1 is enforced in CI. Run `./ktlint --format` before committing.
|
||||
- ktlint 1.2.1 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 — never push directly to `main`.
|
||||
Reference in new issue
Block a user