Deriving a palette from the appearance triple
Every platform in this survey hands an application some subset of the same three scalars — a color scheme, an accent color, a contrast level — and, with the sole exceptions of KDE and Android, none of them hands over a palette. Turning three scalars into a whole design language is the application's job. This page is how.
Last reviewed: August 9, 2026
| Input | (ColorScheme, RgbColor accent, ContrastLevel) |
| Output | a sparkles.ui.theme.Theme |
| Machinery | CIE L* tone · WCAG contrast ratio · hue-preserving tone shifts |
| Acceptance test | body text ≥ 4.5:1, chrome accent ≥ 3:1, in every combination |
| Runnable example | examples/derive-palette.d |
Why not just pick two palettes
The obvious implementation — ship a light theme and a dark theme, switch on the scheme bit — is what sparkles:ui effectively does today via defaultTwoslashPalette(ColorScheme). It handles exactly one of the three inputs. It cannot express an accent (there is nowhere to put it), and it cannot express a contrast level (there is no third theme, and authoring a fourth and a sixth by hand does not scale).
Deriving costs a page of arithmetic and handles all three, plus every future combination, plus the case KDE presents where the platform supplies real colors that must be honoured rather than approximated.
The tone axis
Work in tone: CIE L*, 0 (black) to 100 (white), the third dimension of Material's HCT. L* is perceptually uniform, so equal tone steps look like equal lightness steps, which is what makes a fixed tone difference a usable design primitive.
Concretely (from the example, which computes rather than tabulates these):
tone grey luminance
0 #000000 0.0000
10 #1B1B1B 0.0110
20 #303030 0.0296
40 #5E5E5E 0.1119
50 #777777 0.1845
60 #919191 0.2831
80 #C6C6C6 0.5647
90 #E2E2E2 0.7605
100 #FFFFFF 1.0000Note how non-linear this is against luminance: tone 50 — the perceptual midpoint — sits at 18% luminance, not 50%. Any derivation that reasons about "50% grey" in luminance or in raw sRGB channels will place its mid-tones far too light.
The tone-delta rule
Material's documentation states the rule the whole tonal-palette approach rests on:
a difference of 40 in HCT tone guarantees a contrast ratio >= 3.0, and a difference of 50 guarantees a contrast ratio >= 4.5
That is a strong claim — it converts an accessibility check into arithmetic. The example sweeps the entire tone axis at each delta and reports the worst case rather than repeating the claim:
ΔT 40: worst 3.152:1 (tone 60→100) vs claimed ≥ 3.0:1 holds
ΔT 50: worst 4.478:1 (tone 50→100) vs claimed ≥ 4.5:1 MISSES by 0.022
smallest ΔT that clears 3.0:1 at every tone: 38.50
smallest ΔT that clears 4.5:1 at every tone: 50.25ΔT 40 holds. ΔT 50 does not — it misses 4.5:1 by 0.022 at the top of the axis. The shortfall is ~0.5%, so the rule remains an excellent design heuristic; it is not, however, the guarantee the wording claims. The true threshold under WCAG 2.x relative luminance is ΔT 50.25, and the worst case is always at the light end (tone 50 → 100), where the luminance curve is steepest relative to tone.
The practical rule this yields, and the one the derivation follows:
Use tone deltas to place colors. Use the contrast ratio to accept them.
A palette that must certify WCAG AA checks the ratio; a palette that merely wants to look right can trust the delta. The example does both — it places by delta and then asserts the ratio, so a future change to the placement that breaks accessibility fails the build rather than the review.
Deriving the Sparkles palette
The input type
struct SystemAppearance
{
ColorScheme scheme;
RgbColor accent;
ContrastLevel contrast; // standard | medium | high
}ContrastLevel has three steps rather than a boolean because Android 14 and Apple have three and the desktops have two; widening at the abstraction and collapsing at each backend loses nothing, while the reverse loses the middle step permanently.
Placement
| Role | Light scheme | Dark scheme | Rationale |
|---|---|---|---|
| page background | tone 98 | tone 12 | not pure white/black — pure extremes are fatiguing and leave no room to place surface |
| body text | bg − ΔT | bg + ΔT | ΔT = 50 / 60 / 75 by contrast level |
surface (popups) | bg − 4 | bg + 6 | in dark schemes a raised surface must be lighter; shadows do not read — the elevation rule iOS encodes as userInterfaceLevel |
chromeAccent (text) | accent at tone 40 | accent at tone 80 | accent-as-text must clear 3:1 against the page |
chromeFocused (fill) | accent at tone 90 | accent at tone 30 | accent-as-fill sits near the page so text on it stays legible |
selection | accent at tone 85 | accent at tone 35 | a tint under text, so closer to the page than the fill |
The chromeAccent/chromeFocused split is the same distinction libadwaita draws with adw_accent_color_to_rgba() versus adw_accent_color_to_standalone_rgba() (libadwaita): the same hue cannot be both a fill and text on the page. Treating the accent as one color gets one of the two wrong.
Moving a color to a tone
A full HCT round trip solves for chroma at the target tone through CAM16. The example uses a cheaper approximation — scale the channels in linear light, so the ratios between them (and therefore the hue) survive, then clamp:
const k = luminanceFromTone(targetTone) / luminance(c);
// each channel: linearize → × k → clamp → re-encodeThis is accurate enough for chrome and is @safe pure nothrow @nogc-shaped with no tables. It loses chroma at extreme tones, where the clamp bites, and a future implementation that needs saturated accents at tone 90 will want the real solve. Recorded as a known limitation rather than discovered later.
The result
Running the derivation on the accent GNOME actually reported on the authoring machine (#3584E4):
OS accent: #3584E4 (GNOME 'blue')
light / standard fg #727272 bg #F9F9F9 accent #245FA6
text/bg 4.57:1 accent/bg 6.11:1
light / high fg #373737 bg #F9F9F9 accent #245FA6
text/bg 11.31:1 accent/bg 6.11:1
dark / standard fg #969696 bg #1F1F1F accent #54C7FF
text/bg 5.57:1 accent/bg 8.63:1
dark / high fg #DADADA bg #1F1F1F accent #54C7FF
text/bg 11.79:1 accent/bg 8.63:1Every combination clears WCAG AA for body text and AA-large for the accent, and the example asserts exactly that — so the property is enforced by CI rather than by inspection.
NOTE
The derived body text at standard contrast (4.57:1) is deliberately close to the 4.5 floor. That is what "standard" means: a comfortable reading contrast, not a maximal one. The high rows show the range the contrast preference buys.
Scheme inference vs. the OS answer
Sparkles today infers the scheme from a background color, in sparkles.ui.style.schemeForBackground, using Rec. 601 luma with a threshold of 110. Windows' documented method uses a third formula, (5G + 2R + B) > 8 × 128. How much do they actually disagree?
color Rec601 tone schemeForBackground tone < 50
#686868 104 44.0 dark dark grey
#6D6D6D 109 46.0 dark dark grey
#6F6F6F 111 46.8 light dark <-- disagree grey
#727272 114 48.0 light dark <-- disagree grey
#747474 116 48.8 light dark <-- disagree grey
#008000 75 46.2 dark dark saturated green
#1E1E2E 31 12.0 dark dark catppuccin-mocha base
#282C34 43 17.9 dark dark one-dark base
#002B36 31 15.5 dark dark solarized-dark base
#FDF6E3 245 97.0 light light solarized-light baseThe disagreement is confined to a ~3-tone band of mid greys (tone 46.8–48.8), and every real theme background in the table agrees. That is the honest conclusion, and it points somewhere more useful than "fix the threshold":
The existing heuristic is fine. The problem with inference is not that it classifies badly — it is that a fixed theme has a fixed background, so inference can only ever report what the theme already decided. It can never report what the user wants.
Which is the entire argument for this research tree: the fix is not a better threshold, it is asking the platform.
Where the derivation must not reach
A derivation that retints everything produces a coherent but wrong result. Two categories must survive it untouched:
- Semantic status colors.
error,warn,info,diffAdded,diffRemovedmean something. An error that follows a green desktop accent stops being an error. Apple's HIG states the rule directly: preserve meaning across appearances. - The syntax channel. A user who chose
catppuccin-mochachose those keyword colors. Following the system accent into syntax rules would overwrite an explicit aesthetic choice with an implicit one.
What should follow is chrome: page fore/background, panel surfaces, gutters, scrollbars, the focused-pane band, the selection tint, the caret. That division — chrome follows the system, content follows the theme — is the policy the comparison states and the proposal implements.
Sources
- Material Design 3 — how the color system works — HCT, tonal palettes, the tone-delta rule quoted above
- AOSP — Dynamic color — the tonal-palette recipes and 13 tone indices
- WCAG 2.2 — contrast minimum and relative luminance
- Support Dark and Light themes in Win32 apps — the
(5G+2R+B)brightness formula libs/ui/src/sparkles/ui/style.d—schemeForBackground, the Rec. 601 baseline compared above- All measurements on this page are produced by
examples/derive-palette.d, which CI compiles and runs