From 125ff58f87767a0dba5e09e2723caf675e3035cb Mon Sep 17 00:00:00 2001 From: Gabriel Fontes Date: Sat, 4 Jul 2026 17:44:18 -0300 Subject: [PATCH] feat(lib): add DynamicScheme + MD3 role layer (brick 4) Port material-colors 0.4.2's DynamicColor engine for the rainbow scheme at standard contrast: contrast helpers (ratio_of_tones, lighter/darker, foreground_tone), the full getTone resolution (contrast curves, toneDeltaPair expansion, [50,60) avoidance, second-background), and all 49 MaterialDynamicColors roles (mono/fidelity branches collapsed since rainbow is neither). Validated bit-exact against matugen 4.0's colors output for #2B3975: 49/49 roles in dark AND light (98/98). Also matches the crate's own Rust test vectors for #0000FF (the one delta was a master-vs-0.4.2 version diff). Brick 4 of the pure-Nix matugen effort. Assisted-by: pi (opus-4.8) --- lib/scheme.nix | 315 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 lib/scheme.nix diff --git a/lib/scheme.nix b/lib/scheme.nix new file mode 100644 index 00000000..fc625d62 --- /dev/null +++ b/lib/scheme.nix @@ -0,0 +1,315 @@ +# DynamicScheme + MaterialDynamicColors role layer (brick 4), ported from +# material-colors 0.4.2 for the rainbow scheme at standard contrast (the +# config's settings). Rainbow is neither monochrome nor fidelity, so those +# branches collapse to the default case. +{lib}: let + h = import ./hct.nix {inherit lib;}; + m = import ./math.nix {inherit lib;}; + p = import ./palettes.nix {inherit lib;}; + inherit (h) yFromLstar lstarFromY; + inherit (builtins) floor; + abs = m.abs; + + clamp = lo: hi: x: + if x < lo + then lo + else if x > hi + then hi + else x; + round = x: floor (x + 0.5); + lerp = a: b: t: (1.0 - t) * a + t * b; + maxf = a: b: + if a > b + then a + else b; + minf = a: b: + if a < b + then a + else b; + between = lo: hi: x: x >= lo && x < hi; + + # --- contrast --- + ratioOfYs = y1: y2: let + li = maxf y1 y2; + dk = minf y1 y2; + in (li + 5.0) / (dk + 5.0); + ratioOfTones = a: b: ratioOfYs (yFromLstar (clamp 0.0 100.0 a)) (yFromLstar (clamp 0.0 100.0 b)); + lighter = tone: ratio: + if !(between 0.0 100.01 tone) + then -1.0 + else let + darkY = yFromLstar tone; + lightY = ratio * (darkY + 5.0) - 5.0; + rc = ratioOfYs lightY darkY; + d = abs (rc - ratio); + in + if rc < ratio && d > 0.04 + then -1.0 + else let rv = lstarFromY lightY + 0.4; in + if rv < 0.0 || rv > 100.0 + then -1.0 + else rv; + darker = tone: ratio: + if !(between 0.0 100.01 tone) + then -1.0 + else let + lightY = yFromLstar tone; + darkY = (lightY + 5.0) / ratio - 5.0; + rc = ratioOfYs lightY darkY; + d = abs (rc - ratio); + in + if rc < ratio && d > 0.04 + then -1.0 + else let rv = lstarFromY darkY - 0.4; in + if rv < 0.0 || rv > 100.0 + then -1.0 + else rv; + lighterUnsafe = tone: ratio: let x = lighter tone ratio; in + if x < 0.0 + then 100.0 + else x; + darkerUnsafe = tone: ratio: let x = darker tone ratio; in + if x < 0.0 + then 0.0 + else x; + tonePrefersLight = tone: (round tone) < 60.0; + toneAllowsLight = tone: (round tone) <= 49.0; + foregroundTone = bgTone: ratio: let + lt = lighterUnsafe bgTone ratio; + dt = darkerUnsafe bgTone ratio; + lr = ratioOfTones lt bgTone; + dr = ratioOfTones dt bgTone; + in + if tonePrefersLight bgTone + then + if lr >= ratio || lr >= dr || (abs (lr - dr) < 0.1 && lr < ratio && dr < ratio) + then lt + else dt + else if dr >= ratio || dr >= lr + then dt + else lt; + + ccGet = cc: cl: + if cl <= -1.0 + then cc.low + else if cl < 0.0 + then lerp cc.low cc.normal (cl + 1.0) + else if cl < 0.5 + then lerp cc.normal cc.medium (cl / 0.5) + else if cl < 1.0 + then lerp cc.medium cc.high ((cl - 0.5) / 0.5) + else cc.high; + cc = low: normal: medium: high: {inherit low normal medium high;}; + + # --- role table (rec: roles reference each other) --- + highestSurface = s: + if s.isDark + then roles.surface_bright + else roles.surface_dim; + # defaults keep every role total: null background/curve/pair unless set + R = attrs: + { + isBackground = false; + background = null; + secondBackground = null; + contrastCurve = null; + toneDeltaPair = null; + } + // attrs; + tdp = subject: basis: delta: polarity: stayTogether: _: {inherit subject basis delta polarity stayTogether;}; + + roles = rec { + background = R {name = "background"; palette = "neutral"; isBackground = true; tone = s: if s.isDark then 6.0 else 98.0;}; + on_background = R {name = "on_background"; palette = "neutral"; tone = s: if s.isDark then 90.0 else 10.0; background = _: background; contrastCurve = cc 3.0 3.0 4.5 7.0;}; + surface = R {name = "surface"; palette = "neutral"; isBackground = true; tone = s: if s.isDark then 6.0 else 98.0;}; + surface_dim = R {name = "surface_dim"; palette = "neutral"; isBackground = true; tone = s: if s.isDark then 6.0 else ccGet (cc 87.0 87.0 80.0 75.0) s.contrastLevel;}; + surface_bright = R {name = "surface_bright"; palette = "neutral"; isBackground = true; tone = s: if s.isDark then ccGet (cc 24.0 24.0 29.0 34.0) s.contrastLevel else 98.0;}; + surface_container_lowest = R {name = "surface_container_lowest"; palette = "neutral"; isBackground = true; tone = s: if s.isDark then ccGet (cc 4.0 4.0 2.0 0.0) s.contrastLevel else 100.0;}; + surface_container_low = R {name = "surface_container_low"; palette = "neutral"; isBackground = true; tone = s: if s.isDark then ccGet (cc 10.0 10.0 11.0 12.0) s.contrastLevel else ccGet (cc 96.0 96.0 96.0 95.0) s.contrastLevel;}; + surface_container = R {name = "surface_container"; palette = "neutral"; isBackground = true; tone = s: if s.isDark then ccGet (cc 12.0 12.0 16.0 20.0) s.contrastLevel else ccGet (cc 94.0 94.0 92.0 90.0) s.contrastLevel;}; + surface_container_high = R {name = "surface_container_high"; palette = "neutral"; isBackground = true; tone = s: if s.isDark then ccGet (cc 17.0 17.0 21.0 25.0) s.contrastLevel else ccGet (cc 92.0 92.0 88.0 85.0) s.contrastLevel;}; + surface_container_highest = R {name = "surface_container_highest"; palette = "neutral"; isBackground = true; tone = s: if s.isDark then ccGet (cc 22.0 22.0 26.0 30.0) s.contrastLevel else ccGet (cc 90.0 90.0 84.0 80.0) s.contrastLevel;}; + on_surface = R {name = "on_surface"; palette = "neutral"; tone = s: if s.isDark then 90.0 else 10.0; background = highestSurface; contrastCurve = cc 4.5 7.0 11.0 21.0;}; + surface_variant = R {name = "surface_variant"; palette = "neutral_variant"; isBackground = true; tone = s: if s.isDark then 30.0 else 90.0;}; + on_surface_variant = R {name = "on_surface_variant"; palette = "neutral_variant"; tone = s: if s.isDark then 80.0 else 30.0; background = highestSurface; contrastCurve = cc 3.0 4.5 7.0 11.0;}; + inverse_surface = R {name = "inverse_surface"; palette = "neutral"; tone = s: if s.isDark then 90.0 else 20.0;}; + inverse_on_surface = R {name = "inverse_on_surface"; palette = "neutral"; tone = s: if s.isDark then 20.0 else 95.0; background = _: inverse_surface; contrastCurve = cc 4.5 7.0 11.0 21.0;}; + outline = R {name = "outline"; palette = "neutral_variant"; tone = s: if s.isDark then 60.0 else 50.0; background = highestSurface; contrastCurve = cc 1.5 3.0 4.5 7.0;}; + outline_variant = R {name = "outline_variant"; palette = "neutral_variant"; tone = s: if s.isDark then 30.0 else 80.0; background = highestSurface; contrastCurve = cc 1.0 1.0 3.0 4.5;}; + shadow = R {name = "shadow"; palette = "neutral"; tone = _: 0.0;}; + scrim = R {name = "scrim"; palette = "neutral"; tone = _: 0.0;}; + surface_tint = R {name = "surface_tint"; palette = "primary"; isBackground = true; tone = s: if s.isDark then 80.0 else 40.0;}; + + primary = R {name = "primary"; palette = "primary"; isBackground = true; tone = s: if s.isDark then 80.0 else 40.0; background = highestSurface; contrastCurve = cc 3.0 4.5 7.0 7.0; toneDeltaPair = tdp primary_container primary 10.0 "Nearer" false;}; + on_primary = R {name = "on_primary"; palette = "primary"; tone = s: if s.isDark then 20.0 else 100.0; background = _: primary; contrastCurve = cc 3.0 7.0 11.0 21.0;}; + primary_container = R {name = "primary_container"; palette = "primary"; isBackground = true; tone = s: if s.isDark then 30.0 else 90.0; background = highestSurface; contrastCurve = cc 1.0 1.0 3.0 4.5; toneDeltaPair = tdp primary_container primary 10.0 "Nearer" false;}; + on_primary_container = R {name = "on_primary_container"; palette = "primary"; tone = s: if s.isDark then 90.0 else 10.0; background = _: primary_container; contrastCurve = cc 4.5 7.0 11.0 21.0;}; + inverse_primary = R {name = "inverse_primary"; palette = "primary"; tone = s: if s.isDark then 40.0 else 80.0; background = _: inverse_surface; contrastCurve = cc 3.0 4.5 7.0 7.0;}; + + secondary = R {name = "secondary"; palette = "secondary"; isBackground = true; tone = s: if s.isDark then 80.0 else 40.0; background = highestSurface; contrastCurve = cc 3.0 4.5 7.0 7.0; toneDeltaPair = tdp secondary_container secondary 10.0 "Nearer" false;}; + on_secondary = R {name = "on_secondary"; palette = "secondary"; tone = s: if s.isDark then 20.0 else 100.0; background = _: secondary; contrastCurve = cc 4.5 7.0 11.0 21.0;}; + secondary_container = R {name = "secondary_container"; palette = "secondary"; isBackground = true; tone = s: if s.isDark then 30.0 else 90.0; background = highestSurface; contrastCurve = cc 1.0 1.0 3.0 4.5; toneDeltaPair = tdp secondary_container secondary 10.0 "Nearer" false;}; + on_secondary_container = R {name = "on_secondary_container"; palette = "secondary"; tone = s: if s.isDark then 90.0 else 10.0; background = _: secondary_container; contrastCurve = cc 4.5 7.0 11.0 21.0;}; + + tertiary = R {name = "tertiary"; palette = "tertiary"; isBackground = true; tone = s: if s.isDark then 80.0 else 40.0; background = highestSurface; contrastCurve = cc 3.0 4.5 7.0 7.0; toneDeltaPair = tdp tertiary_container tertiary 10.0 "Nearer" false;}; + on_tertiary = R {name = "on_tertiary"; palette = "tertiary"; tone = s: if s.isDark then 20.0 else 100.0; background = _: tertiary; contrastCurve = cc 4.5 7.0 11.0 21.0;}; + tertiary_container = R {name = "tertiary_container"; palette = "tertiary"; isBackground = true; tone = s: if s.isDark then 30.0 else 90.0; background = highestSurface; contrastCurve = cc 1.0 1.0 3.0 4.5; toneDeltaPair = tdp tertiary_container tertiary 10.0 "Nearer" false;}; + on_tertiary_container = R {name = "on_tertiary_container"; palette = "tertiary"; tone = s: if s.isDark then 90.0 else 10.0; background = _: tertiary_container; contrastCurve = cc 4.5 7.0 11.0 21.0;}; + + error = R {name = "error"; palette = "error"; isBackground = true; tone = s: if s.isDark then 80.0 else 40.0; background = highestSurface; contrastCurve = cc 3.0 4.5 7.0 7.0; toneDeltaPair = tdp error_container error 10.0 "Nearer" false;}; + on_error = R {name = "on_error"; palette = "error"; tone = s: if s.isDark then 20.0 else 100.0; background = _: error; contrastCurve = cc 4.5 7.0 11.0 21.0;}; + error_container = R {name = "error_container"; palette = "error"; isBackground = true; tone = s: if s.isDark then 30.0 else 90.0; background = highestSurface; contrastCurve = cc 1.0 1.0 3.0 4.5; toneDeltaPair = tdp error_container error 10.0 "Nearer" false;}; + on_error_container = R {name = "on_error_container"; palette = "error"; tone = s: if s.isDark then 90.0 else 10.0; background = _: error_container; contrastCurve = cc 4.5 7.0 11.0 21.0;}; + + primary_fixed = R {name = "primary_fixed"; palette = "primary"; isBackground = true; tone = _: 90.0; background = highestSurface; contrastCurve = cc 1.0 1.0 3.0 4.5; toneDeltaPair = tdp primary_fixed primary_fixed_dim 10.0 "Lighter" true;}; + primary_fixed_dim = R {name = "primary_fixed_dim"; palette = "primary"; isBackground = true; tone = _: 80.0; background = highestSurface; contrastCurve = cc 1.0 1.0 3.0 4.5; toneDeltaPair = tdp primary_fixed primary_fixed_dim 10.0 "Lighter" true;}; + on_primary_fixed = R {name = "on_primary_fixed"; palette = "primary"; tone = _: 10.0; background = _: primary_fixed_dim; secondBackground = _: primary_fixed; contrastCurve = cc 4.5 7.0 11.0 21.0;}; + on_primary_fixed_variant = R {name = "on_primary_fixed_variant"; palette = "primary"; tone = _: 30.0; background = _: primary_fixed_dim; secondBackground = _: primary_fixed; contrastCurve = cc 3.0 4.5 7.0 11.0;}; + + secondary_fixed = R {name = "secondary_fixed"; palette = "secondary"; isBackground = true; tone = _: 90.0; background = highestSurface; contrastCurve = cc 1.0 1.0 3.0 4.5; toneDeltaPair = tdp secondary_fixed secondary_fixed_dim 10.0 "Lighter" true;}; + secondary_fixed_dim = R {name = "secondary_fixed_dim"; palette = "secondary"; isBackground = true; tone = _: 80.0; background = highestSurface; contrastCurve = cc 1.0 1.0 3.0 4.5; toneDeltaPair = tdp secondary_fixed secondary_fixed_dim 10.0 "Lighter" true;}; + on_secondary_fixed = R {name = "on_secondary_fixed"; palette = "secondary"; tone = _: 10.0; background = _: secondary_fixed_dim; secondBackground = _: secondary_fixed; contrastCurve = cc 4.5 7.0 11.0 21.0;}; + on_secondary_fixed_variant = R {name = "on_secondary_fixed_variant"; palette = "secondary"; tone = _: 30.0; background = _: secondary_fixed_dim; secondBackground = _: secondary_fixed; contrastCurve = cc 3.0 4.5 7.0 11.0;}; + + tertiary_fixed = R {name = "tertiary_fixed"; palette = "tertiary"; isBackground = true; tone = _: 90.0; background = highestSurface; contrastCurve = cc 1.0 1.0 3.0 4.5; toneDeltaPair = tdp tertiary_fixed tertiary_fixed_dim 10.0 "Lighter" true;}; + tertiary_fixed_dim = R {name = "tertiary_fixed_dim"; palette = "tertiary"; isBackground = true; tone = _: 80.0; background = highestSurface; contrastCurve = cc 1.0 1.0 3.0 4.5; toneDeltaPair = tdp tertiary_fixed tertiary_fixed_dim 10.0 "Lighter" true;}; + on_tertiary_fixed = R {name = "on_tertiary_fixed"; palette = "tertiary"; tone = _: 10.0; background = _: tertiary_fixed_dim; secondBackground = _: tertiary_fixed; contrastCurve = cc 4.5 7.0 11.0 21.0;}; + on_tertiary_fixed_variant = R {name = "on_tertiary_fixed_variant"; palette = "tertiary"; tone = _: 30.0; background = _: tertiary_fixed_dim; secondBackground = _: tertiary_fixed; contrastCurve = cc 3.0 4.5 7.0 11.0;}; + }; + + # --- getTone: the DynamicColor contrast/tone resolution --- + getTone = role: s: let + cl = s.contrastLevel; + decreasing = cl < 0.0; + in + if role.toneDeltaPair != null + then let + pair = role.toneDeltaPair s; + roleA = pair.subject; + roleB = pair.basis; + delta = pair.delta; + polarity = pair.polarity; + stayTogether = pair.stayTogether; + bgTone = getTone (role.background s) s; + aIsNearer = polarity == "Nearer" || (polarity == "Lighter" && !s.isDark) || (polarity == "Darker" && s.isDark); + nearer = + if aIsNearer + then roleA + else roleB; + farther = + if aIsNearer + then roleB + else roleA; + amNearer = role.name == nearer.name; + dir = + if s.isDark + then 1.0 + else -1.0; + nContrast = ccGet nearer.contrastCurve cl; + fContrast = ccGet farther.contrastCurve cl; + nInitial = nearer.tone s; + nTone0 = + if decreasing + then foregroundTone bgTone nContrast + else if ratioOfTones bgTone nInitial >= nContrast + then nInitial + else foregroundTone bgTone nContrast; + fInitial = farther.tone s; + fTone0 = + if decreasing + then foregroundTone bgTone fContrast + else if ratioOfTones bgTone fInitial >= fContrast + then fInitial + else foregroundTone bgTone fContrast; + # expand to satisfy delta + exp = + if (fTone0 - nTone0) * dir >= delta + then {n = nTone0; f = fTone0;} + else let f1 = clamp 0.0 100.0 (delta * dir + nTone0); in + if (f1 - nTone0) * dir >= delta + then {n = nTone0; f = f1;} + else {n = clamp 0.0 100.0 (0.0 - delta * dir + f1); f = f1;}; + # avoid the [50,60) awkward zone + adj = + if between 50.0 60.0 exp.n + then + if dir > 0.0 + then {n = 60.0; f = maxf exp.f (delta * dir + 60.0);} + else {n = 49.0; f = minf exp.f (delta * dir + 49.0);} + else if between 50.0 60.0 exp.f + then + if stayTogether + then ( + if dir > 0.0 + then {n = 60.0; f = maxf exp.f (delta * dir + 60.0);} + else {n = 49.0; f = minf exp.f (delta * dir + 49.0);} + ) + else {n = exp.n; f = if dir > 0.0 then 60.0 else 49.0;} + else exp; + in + if amNearer + then adj.n + else adj.f + else let + answer0 = role.tone s; + in + if role.background == null + then answer0 + else let + bgTone = getTone (role.background s) s; + desired = ccGet role.contrastCurve cl; + answer1 = + if ratioOfTones bgTone answer0 >= desired && !decreasing + then answer0 + else foregroundTone bgTone desired; + answer2 = + if role.isBackground && between 50.0 60.0 answer1 + then + if ratioOfTones 49.0 bgTone >= desired + then 49.0 + else 60.0 + else answer1; + in + if role.secondBackground == null + then answer2 + else let + bgt1 = getTone (role.background s) s; + bgt2 = getTone (role.secondBackground s) s; + upper = maxf bgt1 bgt2; + lower = minf bgt1 bgt2; + lightOption = lighter upper desired; + darkOption = darker lower desired; + prefersLight = tonePrefersLight bgt1 || tonePrefersLight bgt2; + availables = builtins.filter (x: abs (x - (-1.0)) > 1.0e-12) [lightOption darkOption]; + in + if ratioOfTones upper answer2 >= desired && ratioOfTones lower answer2 >= desired + then answer2 + else if prefersLight + then (if lightOption < 0.0 then 100.0 else lightOption) + else if builtins.length availables == 1 + then builtins.head availables + else (if darkOption < 0.0 then 0.0 else darkOption); + + mkScheme = isDark: sourceHex: let + src = h.hctFromHex sourceHex; + in { + inherit isDark; + contrastLevel = 0.0; + sourceColorHct = src; + palettes = p.rainbowPalettes {inherit (src) hue chroma;}; + }; + + colorOf = roleName: s: let + role = roles.${roleName}; + tone = getTone role s; + in + s.palettes.${role.palette}.tone tone; +in { + inherit roles mkScheme getTone colorOf; + + # all role colors for a source hex + mode + colorsFor = isDark: sourceHex: let + s = mkScheme isDark sourceHex; + in + lib.mapAttrs (n: _: colorOf n s) roles; +}