Theming
@flyze/lib-core-angular 1.0.0-alpha.34· latestFlyzeThemingService 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.
How a theme becomes CSS
Section titled “How a theme becomes CSS”Everything funnels through one flat Record<string, string> and one <style> element:
#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.#setTheme(name, variation)reads the theme’slightordarkhalf and pushes each group into the variable record (flyze-theming.service.ts:339).#generateCSSVariables()renders the record as--key: value;pairs and#updateRootStyles()assigns:root { … }to the element’sinnerHTML(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.
Color scheme resolution
Section titled “Color scheme resolution”Three observables, in order of authority
(flyze-theming.service.ts:36–:154):
| Property | Values | Meaning |
|---|---|---|
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'.
Registering a theme
Section titled “Registering a theme”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.
Reading values back
Section titled “Reading values back”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.
Limitations
Section titled “Limitations”getThemeValueGradiationreturns 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 throughsetMultipleRootVariablesit becomes--x: color-mix(…);;, which browsers tolerate; used anywhere that expects a bare value it does not work. Strip it at the call site.generateGradiationPaletteinherits this for every entry except the base color.ngOnDestroynever 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
matchMedialistener is never removed. Thefinalizehandler passesmatchMediaHelper, but the listener registered was an inline arrow wrapping it (flyze-theming.service.ts:399), soremoveEventListenermatches 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-sizedeclaration 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.
Related
Section titled “Related”- The
/themingroute 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