This document explains the non-obvious architectural decisions in Halogen. Each follows the format: context, decision, consequences.
For the full design specification, see PLAN.md.
Context: Material 3 has 49 color roles per scheme (98 total for light + dark). We need the LLM to produce the right colors for a theme, but Gemini Nano has a ~256 output token limit and smaller models are less reliable at generating large structured outputs.
Decision: The LLM generates only 6 seed colors + 6 hints (~12 JSON fields, ~70 tokens). The library expands seeds into the full 49-role palette using HCT tonal palette math.
Consequences:
- Output stays under 256 tokens even with 10 custom extensions
- Works with weaker/smaller LLMs that struggle with large outputs
- Color harmony is guaranteed by the tonal palette algorithm rather than relying on the LLM to pick 49 coordinated colors
- One LLM call produces both light and dark themes (same seeds, different tone mappings)
- Trade-off: developers cannot override individual color roles from the LLM - they control seeds and the expansion config
Context: Halogen generates theme data that needs to be applied to Compose UI. We could either build a custom design system or wrap the existing Material 3 MaterialTheme.
Decision: HalogenTheme composable calls MaterialTheme internally. Every existing M3 component works without modification. A themeWrapper overload allows non-M3 usage.
Consequences:
- Migration is one line of code: wrap your existing
MaterialThemewithHalogenTheme - Every M3 component (
Button,Card,TextField, etc.) just works - Developers keep access to
MaterialTheme.colorScheme,MaterialTheme.typography,MaterialTheme.shapes - Custom theme systems are supported via the
themeWrapperparameter or by consumingExpandedThemedirectly fromhalogen-core - Trade-off: the default path is coupled to M3, but the core data types (
HalogenColorScheme,HalogenTypography,HalogenShapes) are M3-agnostic
Context: Gemini Nano is Android-only (Pixel 9+, Samsung S24+). Halogen is Kotlin Multiplatform. We need LLM inference on iOS, Desktop, and Web too.
Decision: HalogenLlmProvider is a simple interface in halogen-core with zero LLM SDK dependencies. Gemini Nano is shipped as a separate artifact (halogen-provider-nano). Developers implement the interface for any cloud provider.
Consequences:
halogen-coreandhalogen-enginehave zero LLM imports - pure Kotlin- A developer using only OpenAI never pulls ML Kit
- Single provider model: pick one provider (on-device or cloud) per engine instance
- Any LLM works: OpenAI, Claude, Gemini Cloud, Ollama, custom endpoints
- Trade-off: no built-in cloud provider - developers bring their own (typically 20-30 lines of code)
Context: LLM inference is slow (~1-3 seconds). Most apps have repeating contexts (screens, categories, brands) that should reuse previously generated themes.
Decision: Themes are stored by string key: any developer-defined identifier (a route, a subreddit, a category, a brand name). The resolve chain is: memory cache -> remote themes -> LLM provider -> default. In-memory LRU cache with configurable max entries (default 20).
Consequences:
- The LLM is called once per key, ever (until explicitly evicted)
- Hot themes resolve in nanoseconds (HashMap lookup)
- Contextual theming is a first-class feature: different parts of an app naturally get different themes
- Server-provided themes take priority over LLM-generated ones, enabling brand control
- Cache is observable via
StateFlow<CacheStats>andFlow<CacheEvent> - Trade-off: memory grows with unique keys. LRU eviction handles this, but developers should be aware of the cache size for apps with many unique keys.
Context: Material 3's color system uses the HCT (Hue-Chroma-Tone) color space. Google publishes this as material-color-utilities in Java, but that's Android-only and blocks KMP.
Options considered:
- Depend on
material-color-utilities(Java): Zero effort, but Android-only. Kills KMP. expect/actualwrapper: Still need to solve iOS/Web. Multiple implementations to maintain.- Pure Kotlin in
commonMain: ~450 lines of math. Runs everywhere. Single implementation.
Decision: Port the color science to pure Kotlin. CAM16 color appearance model, HCT conversion, tonal palette generation, and M3 scheme mapping - all in commonMain.
Consequences:
- Enables KMP with zero
expect/actualfor color math - Single implementation, fully testable in
commonTestagainst reference outputs - No transitive Java dependency for any platform
- Maintenance is minimal: HCT and M3 tone mapping are stable specs
- ~450 lines total: HCT/sRGB conversion (~200), TonalPalette (~100), DynamicScheme mapping (~150)
- Validated against Google's Java implementation with exact hex-value assertions
Context: LLM token limits are real constraints. Gemini Nano recommends ~256 output tokens. Every character in the JSON response counts.
Decision: Use abbreviated @SerialName keys (pri instead of primary, neuL instead of neutralLight, hw instead of headingWeight, etc.) to minimize output tokens.
Consequences:
- Baseline output is ~65-80 tokens, well under the 256 limit
- Room for 10+ custom extensions without hitting limits
- Works with weaker models that have shorter context windows
- Trade-off: raw JSON is less human-readable, but developers never see it -
HalogenThemeSpechas descriptive Kotlin property names - The spec uses
ignoreUnknownKeys = trueandisLenient = truefor resilient parsing of LLM output