# === WCAG 2.1 contrast ratio === def hex-to-rgb [hex: string] { let clean = ($hex | str trim | str substring 1..) [ ((($clean | str substring 0..<2) | into int --radix 16) / 255.0), ((($clean | str substring 2..<4) | into int --radix 16) / 255.0), ((($clean | str substring 4..<6) | into int --radix 16) / 255.0), ] } def srgb-linearize [c: float] { if $c <= 0.04045 { $c / 12.92 } else { (($c + 0.055) / 1.055) ** 2.4 } } def relative-luminance [hex: string] { let rgb = (hex-to-rgb $hex) let r = (0.2126 * (srgb-linearize $rgb.0)) let g = (0.7152 * (srgb-linearize $rgb.1)) let b = (0.0722 * (srgb-linearize $rgb.2)) [$r, $g, $b] | math sum } def wcag-contrast [fg: string, bg: string] { let l1 = (relative-luminance $fg) let l2 = (relative-luminance $bg) (([$l1 $l2] | math max) + 0.05) / (([$l1 $l2] | math min) + 0.05) } # minimum contrast thresholds based on Nugu's stated intentions. # error: "nonnegotiable and urgent. very aggressive and visible." → WCAG AA text # warning: "likely to be problems, but not urgent. distracting but ignorable." → large text # info: "context relevant suggestions. relaxed and easy to ignore." → present but not demanding # debug: "easy to spot" → large text # content.normal: default text → WCAG AA text # content.accent: theme identity, prominent → WCAG AA text # content.minor: receded structural text → large text # content.focus: user is actively editing → WCAG AA text # content.unfocus: not in focus, receded → present but not demanding # content.important_local: system demands attention → WCAG AA text # content.important_global: important but lighter variant → between AA and large text # ui.*: same semantics but on ui.backdrop instead of content.backdrop # content.backdrop on semantic colors: semantic colors used as highlight backgrounds const contrast_thresholds = [ { color: "error", bg: "content.backdrop", min: 4.5 }, { color: "warning", bg: "content.backdrop", min: 3.0 }, { color: "info", bg: "content.backdrop", min: 2.5 }, { color: "content.backdrop", bg: "error", min: 4.5 }, { color: "content.backdrop", bg: "warning", min: 4.5 }, { color: "content.backdrop", bg: "info", min: 4.5 }, { color: "debug", bg: "content.backdrop", min: 3.0 }, { color: "content.normal", bg: "content.backdrop", min: 4.5 }, { color: "content.accent", bg: "content.backdrop", min: 4.5 }, { color: "content.minor", bg: "content.backdrop", min: 3.0 }, { color: "content.focus", bg: "content.backdrop", min: 4.5 }, { color: "content.unfocus", bg: "content.backdrop", min: 2.5 }, { color: "content.important_local", bg: "content.backdrop", min: 4.5 }, { color: "content.important_global", bg: "content.backdrop", min: 2.5 }, { color: "ui.normal", bg: "ui.backdrop", min: 4.5 }, { color: "ui.accent", bg: "ui.backdrop", min: 4.5 }, { color: "ui.minor", bg: "ui.backdrop", min: 3.0 }, { color: "ui.focus", bg: "ui.backdrop", min: 4.5 }, { color: "ui.unfocus", bg: "ui.backdrop", min: 2.5 }, { color: "ui.important_local", bg: "ui.backdrop", min: 4.5 }, { color: "ui.important_global", bg: "ui.backdrop", min: 2.5 }, ] def validate-theme [theme: record, theme_name: string] { let pal = $theme.palette let results = ($contrast_thresholds | each {|t| let cparts = ($t.color | split row ".") let bparts = ($t.bg | split row ".") let color_val = if ($cparts | length) == 1 { $pal | get $t.color } else { $pal | get $cparts.0 | get $cparts.1 } let bg_val = if ($bparts | length) == 1 { $pal | get $t.bg } else { $pal | get $bparts.0 | get $bparts.1 } let ratio = (wcag-contrast $color_val $bg_val) { color: $t.color, hex: $color_val, bg_hex: $bg_val, ratio: ($ratio | math round --precision 1), min: $t.min, pass: ($ratio >= $t.min), } }) let failures = ($results | where pass == false) print $"(ansi yellow_bold)WCAG AA: ($theme_name) theme(ansi reset)" let failure_count = ($failures | length) if $failure_count > 0 { for f in $failures { print $" (ansi red)✗(ansi reset) ($f.color) ($f.hex) on ($f.bg_hex) — ($f.ratio):1, need ($f.min):1" } print $" (ansi red_bold)($failure_count) failures(ansi reset)" } else { print $" (ansi green)✓ all colors pass(ansi reset)" } print "" $failure_count } export def check [] { print "" let failure_count = ([ (validate-theme (new) "dark") (validate-theme (new --light) "light") ] | math sum) if $failure_count > 0 { error make { msg: $"($failure_count) Nugu contrast checks failed" } } } def readable-textcolor [bg: string] { pastel textcolor $bg | pastel format hex | ansi strip } export def print-colors [theme: record] { let mat = [ [ { name: "debug", color: $theme.palette.debug } { name: "error", color: $theme.palette.error } { name: "warning", color: $theme.palette.warning } { name: "info", color: $theme.palette.info } null null null null ] [ { name: "normal", color: $theme.palette.content.normal } { name: "backdrop", color: $theme.palette.content.backdrop } { name: "accent", color: $theme.palette.content.accent } { name: "minor", color: $theme.palette.content.minor } { name: "focus", color: $theme.palette.content.focus } { name: "unfocus", color: $theme.palette.content.unfocus } { name: "important_local", color: $theme.palette.content.important_local } { name: "important_global", color: $theme.palette.content.important_global } ] [ { name: "normal", color: $theme.palette.ui.normal } { name: "backdrop", color: $theme.palette.ui.backdrop } { name: "accent", color: $theme.palette.ui.accent } { name: "minor", color: $theme.palette.ui.minor } { name: "focus", color: $theme.palette.ui.focus } { name: "unfocus", color: $theme.palette.ui.unfocus } { name: "important_local", color: $theme.palette.ui.important_local } { name: "important_global", color: $theme.palette.ui.important_global } ] ] let num_x = ($mat | get 0 | length) let num_y = ($mat | length) let bg = "#000000" let cols = (term size | get columns) let pad = 2 let block_w = (($cols - (($num_x - 1) * $pad)) // $num_x // 2) * 2 let block_h = ($block_w // 2) let name_row = (($block_h // 2) - 1) let hex_row = ($block_h // 2) for by in 0..<$num_y { for py in 0..<$block_h { for bx in 0..<$num_x { let item = $mat | get $by | get $bx let color = if ($item | is-not-empty) { $item.color } else { $bg } let has_text = ($item | is-not-empty) and ($block_w >= 8) and ($py == $name_row or $py == $hex_row) if $has_text { let fg_color = (readable-textcolor $color) let label = if $py == $name_row { $item.name } else { $color } let padded = ($label | fill --alignment c --character ' ' --width $block_w) let line = ($padded | str substring 0..<$block_w) print --no-newline $"(ansi --escape { bg: $color, fg: $fg_color })($line)(ansi reset)" } else { let solid = ("" | fill --alignment l --character '█' --width $block_w) print --no-newline $"(ansi --escape { bg: $color, fg: $color })($solid)(ansi reset)" } if $bx < ($num_x - 1) { print --no-newline $"(ansi --escape { bg: $bg, fg: $bg })██(ansi reset)" } } print "" } if $by < ($num_y - 1) { let total_w = ($num_x * $block_w) + (($num_x - 1) * $pad) let solid = ("" | fill --alignment l --character '█' --width $total_w) print --no-newline $"(ansi --escape { bg: $bg, fg: $bg })($solid)(ansi reset)" print "" } } } def make-palette [ debug: string, error: string, warning: string, info: string, content_normal: string, content_backdrop: string, content_accent: string, content_minor: string, content_focus: string, content_unfocus: string, content_important_local: string, content_important_global: string, ui_normal: string, ui_backdrop: string, ui_accent: string, ui_minor: string, ui_focus: string, ui_unfocus: string, ui_important_local: string, ui_important_global: string ] { { palette: { debug: $debug error: $error warning: $warning info: $info content: { normal: $content_normal backdrop: $content_backdrop accent: $content_accent minor: $content_minor focus: $content_focus unfocus: $content_unfocus important_local: $content_important_local important_global: $content_important_global } ui: { normal: $ui_normal backdrop: $ui_backdrop accent: $ui_accent minor: $ui_minor focus: $ui_focus unfocus: $ui_unfocus important_local: $ui_important_local important_global: $ui_important_global } } } } # modifies a color by some cool operation -> flux. # default flux is to darken. def flux [ compensator: float # power of the flux light deescalator: float # weaken the flux charisma --light # use the light variant of flux color modification ] { pastel desaturate $deescalator | pastel ( if $light { "darken" } else { "lighten" } ) $compensator | pastel format hex | ansi strip } def mix [ color: string # color to mix into --fraction: float = 0.42 # OkLab blend fraction ] { pastel mix --colorspace OkLab --fraction $fraction $color } def new [ --light # make the light variant ] { let base = (open base.toml | get palette) def normal_flux [ --c: float=0.069 --d:float=0.42 ] { if $light { flux $c $d --light } else { flux $c $d } } def inverted_flux [ --c: float=0.069 --d:float=0.42 ] { if $light { flux $c $d } else { flux $c $d --light } } let normal = if $light { $base.backdrop } else { $base.normal } let backdrop = if $light { $base.normal } else { $base.backdrop } let error_seed = ($base.error | mix $base.accent --fraction 0.08) let warning_seed = ($base.warning | mix $base.accent --fraction 0.03) let info_seed = ($base.info | mix $base.important --fraction 0.25 | mix $base.backdrop --fraction 0.08) let error = ($error_seed | normal_flux --c 0.12 --d 0.22) let warning = if $light { $warning_seed | flux 0.35 0.25 --light } else { $warning_seed | flux 0.08 0.18 } let info = if $light { $info_seed | flux 0.18 0.25 --light } else { $info_seed | flux 0.12 0.25 } let debug = ( $base.debug | normal_flux --c 0.15 --d 0.2 ) let content_normal = ( $normal | inverted_flux ) let content_backdrop = ( $backdrop | normal_flux ) let content_accent = ( $base.accent | normal_flux ) let content_minor = ( $normal | mix $backdrop | normal_flux ) let content_focus = ( $base.focus | mix $content_normal | mix $base.important | normal_flux ) let content_unfocus = ( $base.focus | mix $content_backdrop | mix $base.important | normal_flux --d 1 ) let content_important_local = ( $base.important | mix $content_normal | mix $base.important | mix $base.accent | normal_flux ) let content_important_global = ( $base.important | mix $content_normal | mix $base.important | mix $base.accent | normal_flux --c (0.42 * 0.5) ) let ui_normal = ( $content_normal | inverted_flux ) let ui_backdrop = ( $content_backdrop | normal_flux ) let ui_accent = ( $content_accent | normal_flux --d 0 ) let ui_minor = ( $content_minor | normal_flux ) let ui_focus = ( $content_focus | normal_flux --d (0.42 * 0.25) ) let ui_unfocus = ( $content_unfocus | normal_flux ) let ui_important_local = ( $content_important_local | normal_flux --d 0 ) let ui_important_global = ( $content_important_global | normal_flux --d 0 ) (make-palette $debug $error $warning $info $content_normal $content_backdrop $content_accent $content_minor $content_focus $content_unfocus $content_important_local $content_important_global $ui_normal $ui_backdrop $ui_accent $ui_minor $ui_focus $ui_unfocus $ui_important_local $ui_important_global) } export def dark [] { new } export def light [] { new --light }