Semantic colour tokens through a CompositionLocal
A Material colour scheme covers roles: surfaces, containers, primary/secondary/tertiary, error and
the on… pairs. It does not cover colours whose meaning is your app's — the pink that means "liked",
the highlight on the active line of a running text, the two tones a shimmer sweeps between, the
overlays that dim artwork. Those need a home that is not a scattering of hex literals.
// adapted
@Immutable
data class AppColors(
val favorite: Color, val lyricActive: Color,
val shimmerBackground: Color, val shimmerLine: Color,
val overlay: Color, val overlayHeavy: Color,
)
private val DarkAppColors = AppColors(favorite = favoriteColor, /* … */)
// Overlays stay dark in both themes: they cover artwork, where content is always light.
private val LightAppColors =
DarkAppColors.copy(shimmerBackground = shimmerBackgroundLight, shimmerLine = shimmerLineLight)
val LocalAppColors = staticCompositionLocalOf { DarkAppColors }
and in the theme, alongside the scheme:
// adapted
CompositionLocalProvider(
LocalAppColors provides if (isDark) DarkAppColors else LightAppColors,
LocalIsDarkTheme provides isDark,
content = content,
)
Traps
Static versus dynamic is a real difference, not a style preference. The two factories build
different provided values, and you can read that straight out of the compiled runtime. On
androidx.compose.runtime:runtime-desktop:1.12.0-alpha02, staticCompositionLocalOf's
StaticProvidableCompositionLocal builds its ProvidedValue with a null mutation policy, a null
backing state and an isDynamic flag of false, where compositionLocalOf's
DynamicProvidableCompositionLocal passes its policy and sets that flag true. So a static local has
nothing a reader can subscribe to — changing it must invalidate everything under the provider, while
a dynamic one is state-backed and invalidates only the composables that read it.
JAR=$(find ~/.gradle/caches/modules-2 -path '*androidx.compose.runtime/runtime-desktop/*' -name '*.jar' \
| sort -V | tail -1) && echo "$JAR" && D=$(mktemp -d) && unzip -oq "$JAR" -d "$D" \
&& javap -c -p -classpath "$D" androidx.compose.runtime.{Static,Dynamic}ProvidableCompositionLocal \
| grep -E "^public final class|defaultProvidedValue|getfield|aconst_null|iconst_[01]$"
Compare the tails of the two defaultProvidedValue$runtime bodies: static ends aconst_null ×3, iconst_0; dynamic swaps getfield … policy for the first null and ends iconst_1. sort -V picks
the newest cached jar, not necessarily what your build resolves — so read the echoed path.
That makes the choice mechanical. Theme tokens change roughly never — a theme switch — and are read in hundreds of places, so pay nothing at read time and accept a full re-theme on a switch: static. A value that changes often and is read in a few places (a scroll offset, a playing item) wants the opposite: dynamic.
@Immutable is a promise the compiler mostly takes your word for. Every property val, every
type stable. A var, a mutable collection or an unstable third-party type inside makes the
annotation a lie, and skipping decisions taken on its strength go wrong silently.
Hoist the instances to top-level vals. Building the token object inside the theme composable
hands the provider a fresh object on every composition, for no reason: two constants and a copy()
cost nothing and are easier to read.
Derive the second theme with copy(), changing only what differs. It documents intent that a
line of prose cannot: overlays sit on artwork, and artwork is light-on-dark in both themes, so they
are deliberately identical. Two full constructors make that look like an oversight someone will fix.
Do not duplicate a role that already exists. A surface tier, an outline, an error, a primary or secondary accent belongs in the scheme: a token shadowing a role stops following seeded or wallpaper-derived theming and drifts when the scheme is regenerated. Derivable from the seed? A role.
The default value is a real fallback, not a placeholder. Previews, tests, and any composition
rooted outside your theme get it — including sheets or windows hosted from a composition you did not
root (force-dark-immersive-subtree covers which of those inherit). Make it your primary set rather
than a throwing lambda, unless you want previews to crash.
A boolean local beats every consumer re-deriving the theme. LocalIsDarkTheme publishes what
the theme decided, which is not the same as what the system reports once any subtree forces a mode.
Platform effects that tint or blur must read the local, never isSystemInDarkTheme().
Never capture a token inside an unkeyed remember. remember { colors.favorite } freezes the
first theme's value for that composable's lifetime, and the bug only appears when a user toggles the
theme without leaving the screen. Key the remember on the token, or just read it directly — a
CompositionLocal read is cheap, and cheapest of all for a static one.
Retire legacy colours by deprecating, not deleting. A colour one screen still needs and nobody
has time to migrate should carry a @Deprecated message naming its single remaining use — that is
what stops it spreading again while the migration waits.
Verifying it
Every local you declare, with its factory, so each can be checked against the static/dynamic rule above:
grep -rn --include='*.kt' -E "staticCompositionLocalOf|compositionLocalOf" . | grep -v '/build/'Every token holder with the two lines above it, so you can check each carries
@Immutable. The command cannot express absence — read the context lines:grep -rn --include='*.kt' -B2 "^data class .*Colors\b" . | grep -v '/build/'Hex literals outside the theme package, which is where tokens leak back out. A one-off decorative colour is fine; the real signal is repetition — the same literal in several files is a token that escaped, so count them rather than reading the list:
grep -rho --include='*.kt' --exclude-dir=build --exclude-dir=theme \ -E "Color\(0x[0-9A-Fa-f]{8}\)" . | sort | uniq -c | sort -rn | headAnything with a count above one belongs in the token class or the scheme. Case matters to
uniq, so0xFFxxxxxxand0xffxxxxxxcount separately — that inconsistency is itself worth fixing.Tokens frozen by an unkeyed
remember. Expect no output; every hit survives a theme change with the old colour:grep -rn --include='*.kt' -E "remember \{[^}]*Local[A-Za-z]+\.current" . | grep -v '/build/'By eye: toggle the theme while sitting on a screen, not by relaunching. A token read through a static local updates with the rest of the subtree; one captured in a
rememberdoes not, and relaunching hides exactly that difference.