Skip to content

Theming

@flyze/lib-core-angular 1.0.0-alpha.34· latest

FlyzeThemingService turns a named theme into CSS custom properties on :root. A component never asks the service for a color — it writes var(--colors-flyze-default) in its stylesheet, and the service decides what that resolves to. Switching theme or color scheme is therefore a repaint, with no Angular involvement and nothing to re-render.

The service is providedIn: 'root' and starts working without any setup: the built-in flyze theme is registered in the constructor and the user’s system color scheme is applied immediately.

Everything funnels through one flat Record<string, string> and one <style> element:

  1. #createStyleElement() creates <style id="root-variables"> in <head>, removing any pre-existing element with that id first (flyze-theming.service.ts:418) — a hot reload or a second bootstrap must not leave two of them behind.
  2. #setTheme(name, variation) reads the theme’s light or dark half and pushes each group into the variable record (flyze-theming.service.ts:339).
  3. #generateCSSVariables() renders the record as --key: value; pairs and #updateRootStyles() assigns :root { … } to the element’s innerHTML (flyze-theming.service.ts:435).

The group names are not part of the variable name. A theme half has three groups — system, colors, platform — but every key inside already carries its own prefix ('colors-flyze-default', 'platform-shell-header', flyze-default-theme.ts:72), and #setTheme merges all three groups into the same flat record. So colors.colors-flyze-default emits --colors-flyze-default, and two groups sharing a key name would silently overwrite each other.

Rationale for the single-style-element design and the merge below: ADR 0004.

Three observables, in order of authority (flyze-theming.service.ts:36:154):

PropertyValuesMeaning
detectedPreferredColorScheme'dark' | 'light'read-only, from prefers-color-scheme
customPreferredColorScheme'dark' | 'light' | 'auto'writable, defaults to 'auto'
preferredColorScheme'dark' | 'light'read-only, the effective result

combineLatest folds the first two into the third — 'auto' passes the detected value through, anything else overrides it — and a second combineLatest over preferredColorScheme$ and theme$ applies the variables. Both are distinctUntilChanged() + shareReplay(1), so a subscriber joining late immediately gets the current value.

#initializeAutoDetectTheme() seeds the detected value from window.matchMedia and listens for change, so a scheme switch in the OS repaints a running app while customPreferredColorScheme === 'auto'.

setThemeConfig(name, palette) restates every default key and spreads the caller’s values over it, per group and per variation (flyze-theming.service.ts:187):

dark: {
colors: { ...FLYZE_THEME.flyze.dark.colors, ...(themePalette.dark?.colors ?? {}) },
}

This is not defensive boilerplate. The merge is against a fully defined structure so that both halves of the theme always have every key. Without it, a theme that only defines light would keep the previous theme’s dark variables on the element when the color scheme flips — the variables are never removed, only overwritten. A partial or deep merge would reintroduce exactly that.

The merge is shallow within a group, and setThemeConfig replaces any previously registered theme of the same name rather than extending it.

theme is validated on assignment: setting a name that was never registered logs a warning and is ignored (flyze-theming.service.ts:114), so theme = 'x' followed by setThemeConfig('x', …) silently does nothing. Register first, then activate.

getThemeValue(themeName, key) takes a dotted path and is typed by it — NestedKeyOf<ThemeStructure> enumerates every legal path, ValueOfPath computes the result type (flyze-default-theme.ts:18), so getThemeValue('flyze', 'light.colors.colors-flyze-default') autocompletes and returns a ThemeColor. Resolution itself is a keys.reduce walk with no runtime validation: a path that does not exist returns undefined.

ThemeColor is a template-literal union covering hex, rgb/rgba and hsl/hsla (flyze-default-theme.ts:1). Named CSS colors and color-mix() are not assignable.

getThemeValueGradiation builds a color-mix(in srgb, <color>, #fff|#000 N%) string, and generateGradiationPalette calls it 18 times to produce <name>-L10-L90 and <name>-D10-D90 around a base color (flyze-theming.service.ts:302) — a ready-made argument for setMultipleRootVariables.

  • getThemeValueGradiation returns a trailing semicolon. The template ends in %); (flyze-theming.service.ts:292), so the string is not a valid CSS value on its own. Fed through setMultipleRootVariables it becomes --x: color-mix(…);;, which browsers tolerate; used anywhere that expects a bare value it does not work. Strip it at the call site. generateGradiationPalette inherits this for every entry except the base color.
  • ngOnDestroy never runs. The service is a root singleton, so the cleanup that removes the style element (flyze-theming.service.ts:158) only executes in tests that destroy the injector.
  • The matchMedia listener is never removed. The finalize handler passes matchMediaHelper, but the listener registered was an inline arrow wrapping it (flyze-theming.service.ts:399), so removeEventListener matches nothing. Harmless given the point above.
  • No theme removal. Themes accumulate in the internal map for the lifetime of the app.
  • Colors only, no sizing. The service can emit nothing but custom properties, so it cannot express the font-size declaration UI scaling needs, and a length has no place in a structure re-merged on every light/dark switch. Sizing is ui-scale’s job — ADR 0010.
  • The /theming route of the playground app is the live demonstration: a mock app shell painted only by the variables, next to a hard-coded card and one whose colors were read into TypeScript, plus the full token palette, the registration order, the gradiation ramp and the name collision below.
  • guides/theming-an-app — registering and activating a theme
  • ui-scale — the other runtime knob on the document root, and why it is separate