///|
/// RGB color with 8-bit channels.
pub(all) struct Color {
r : Int
g : Int
b : Int
} derive(Eq, Debug)
///|
/// A color role inside a design palette or token set.
pub(all) enum Role {
RoleText
RoleAccent
RoleBackground
RoleSurface
RoleBorder
} derive(Eq, Debug)
///|
/// WCAG conformance target.
pub(all) enum Requirement {
LevelAA
LevelAAA
} derive(Eq, Debug)
///|
/// Text size class used by WCAG contrast thresholds.
pub(all) enum TextKind {
NormalText
LargeText
} derive(Eq, Debug)
///|
pub(all) struct Policy {
requirement : Requirement
text_kind : TextKind
} derive(Eq, Debug)
///|
pub(all) struct Swatch {
name : String
color : Color
role : Role
} derive(Eq, Debug)
///|
pub(all) enum Grade {
GradeFail
GradeLarge
GradeAA
GradeAAA
} derive(Eq, Debug)
///|
pub(all) enum Suggestion {
SuggestKeep
SuggestUseBlack
SuggestUseWhite
SuggestIncreaseSeparation
} derive(Eq, Debug)
///|
pub(all) struct PairAudit {
foreground : Swatch
background : Swatch
ratio : Double
required : Double
grade : Grade
passed : Bool
suggestion : Suggestion
} derive(Eq, Debug)
///|
pub(all) struct PaletteReport {
pairs : Array[PairAudit]
checked : Int
failures : Int
} derive(Eq, Debug)
///|
pub(all) enum ParseError {
EmptyInput
InvalidHexLength(Int)
InvalidHexDigit(Char)
InvalidRgbFunction(String)
ComponentOutOfRange(String, Int)
UnknownColorName(String)
} derive(Eq, Debug)
///|
pub(all) enum ColorParse {
ColorParsed(Color)
ColorParseFailed(ParseError)
} derive(Eq, Debug)
///|
pub(all) enum SwatchParse {
SwatchParsed(Swatch)
SwatchParseFailed(String, ParseError)
} derive(Eq, Debug)
///|
priv enum ComponentRead {
ComponentValue(Int)
ComponentProblem(ParseError)
}
///|
pub fn rgb(r : Int, g : Int, b : Int) -> Color? {
if in_channel(r) && in_channel(g) && in_channel(b) {
Some({ r, g, b })
} else {
None
}
}
///|
pub fn swatch(name : String, color : Color, role : Role) -> Swatch {
{ name, color, role }
}
///|
pub fn policy_aa() -> Policy {
{ requirement: LevelAA, text_kind: NormalText }
}
///|
pub fn policy_aaa() -> Policy {
{ requirement: LevelAAA, text_kind: NormalText }
}
///|
pub fn policy_large_aa() -> Policy {
{ requirement: LevelAA, text_kind: LargeText }
}
///|
pub fn policy_large_aaa() -> Policy {
{ requirement: LevelAAA, text_kind: LargeText }
}
///|
pub fn Role::is_foreground(self : Role) -> Bool {
match self {
RoleText | RoleAccent => true
_ => false
}
}
///|
pub fn Role::is_background(self : Role) -> Bool {
match self {
RoleBackground | RoleSurface => true
_ => false
}
}
///|
pub fn Role::label(self : Role) -> String {
match self {
RoleText => "text"
RoleAccent => "accent"
RoleBackground => "background"
RoleSurface => "surface"
RoleBorder => "border"
}
}
///|
pub fn Grade::label(self : Grade) -> String {
match self {
GradeFail => "fail"
GradeLarge => "large-text"
GradeAA => "AA"
GradeAAA => "AAA"
}
}
///|
pub fn Suggestion::label(self : Suggestion) -> String {
match self {
SuggestKeep => "keep"
SuggestUseBlack => "use #000000 for this background"
SuggestUseWhite => "use #FFFFFF for this background"
SuggestIncreaseSeparation => "increase foreground/background separation"
}
}
///|
pub fn Color::to_hex(self : Color) -> String {
"#" +
component_to_hex(self.r) +
component_to_hex(self.g) +
component_to_hex(self.b)
}
///|
pub fn Color::relative_luminance(self : Color) -> Double {
0.2126 * channel_luminance(self.r) +
0.7152 * channel_luminance(self.g) +
0.0722 * channel_luminance(self.b)
}
///|
pub fn contrast_ratio(a : Color, b : Color) -> Double {
let la = a.relative_luminance()
let lb = b.relative_luminance()
let lighter = if la >= lb { la } else { lb }
let darker = if la >= lb { lb } else { la }
(lighter + 0.05) / (darker + 0.05)
}
///|
pub fn minimum_ratio(policy : Policy) -> Double {
match policy {
{ requirement: LevelAA, text_kind: NormalText } => 4.5
{ requirement: LevelAA, text_kind: LargeText } => 3.0
{ requirement: LevelAAA, text_kind: NormalText } => 7.0
{ requirement: LevelAAA, text_kind: LargeText } => 4.5
}
}
///|
pub fn grade_ratio(ratio : Double) -> Grade {
if ratio >= 7.0 {
GradeAAA
} else if ratio >= 4.5 {
GradeAA
} else if ratio >= 3.0 {
GradeLarge
} else {
GradeFail
}
}
///|
pub fn audit_pair(
foreground : Swatch,
background : Swatch,
policy : Policy,
) -> PairAudit {
let ratio = contrast_ratio(foreground.color, background.color)
let required = minimum_ratio(policy)
let passed = ratio >= required
let suggestion = if passed {
SuggestKeep
} else {
choose_suggestion(background.color, required)
}
{
foreground,
background,
ratio,
required,
grade: grade_ratio(ratio),
passed,
suggestion,
}
}
///|
pub fn audit_palette(
swatches : Array[Swatch],
policy : Policy,
) -> PaletteReport {
let pairs : Array[PairAudit] = []
for foreground in swatches {
if foreground.role.is_foreground() {
for background in swatches {
if background.role.is_background() {
pairs.push(audit_pair(foreground, background, policy))
}
}
}
}
let mut failures = 0
for pair in pairs {
if !pair.passed {
failures += 1
}
}
{ pairs, checked: pairs.length(), failures }
}
///|
pub fn PaletteReport::passes(self : PaletteReport) -> Bool {
self.failures == 0
}
///|
pub fn PaletteReport::to_markdown(self : PaletteReport) -> String {
let out = StringBuilder()
out.write_string("# PaletteGuard report\n\n")
out.write_string("Checked pairs: " + self.checked.to_string() + "\n")
out.write_string("Failures: " + self.failures.to_string() + "\n\n")
out.write_string(
"| foreground | background | ratio | required | grade | result | suggestion |\n",
)
out.write_string("| --- | --- | ---: | ---: | --- | --- | --- |\n")
for pair in self.pairs {
out.write_string("| ")
out.write_string(pair.foreground.name)
out.write_string(" ")
out.write_string(pair.foreground.color.to_hex())
out.write_string(" | ")
out.write_string(pair.background.name)
out.write_string(" ")
out.write_string(pair.background.color.to_hex())
out.write_string(" | ")
out.write_string(format_ratio(pair.ratio))
out.write_string(" | ")
out.write_string(format_ratio(pair.required))
out.write_string(" | ")
out.write_string(pair.grade.label())
out.write_string(" | ")
out.write_string(if pair.passed { "pass" } else { "fail" })
out.write_string(" | ")
out.write_string(pair.suggestion.label())
out.write_string(" |\n")
}
out.to_string()
}
///|
pub fn parse_color(input : String) -> ColorParse {
let token = input.trim().to_lower().to_owned()
if token.length() == 0 {
return ColorParseFailed(EmptyInput)
}
if char_at(token, 0) == '#' {
parse_hex_color(token)
} else if is_rgb_function(token) {
parse_rgb_function(token)
} else {
parse_named_color(token)
}
}
///|
pub fn parse_swatch(
name : String,
color_token : String,
role : Role,
) -> SwatchParse {
match parse_color(color_token) {
ColorParsed(color) => SwatchParsed({ name, color, role })
ColorParseFailed(error) => SwatchParseFailed(name, error)
}
}
///|
pub fn demo_palette() -> Array[Swatch] {
[
swatch("ink", unchecked_color(18, 24, 38), RoleText),
swatch("muted", unchecked_color(120, 128, 140), RoleText),
swatch("brand", unchecked_color(0, 105, 190), RoleAccent),
swatch("paper", unchecked_color(255, 255, 255), RoleBackground),
swatch("mist", unchecked_color(236, 240, 245), RoleSurface),
swatch("line", unchecked_color(180, 188, 198), RoleBorder),
]
}
///|
pub fn demo_report() -> String {
audit_palette(demo_palette(), policy_aa()).to_markdown()
}
///|
fn in_channel(value : Int) -> Bool {
value >= 0 && value <= 255
}
///|
fn unchecked_color(r : Int, g : Int, b : Int) -> Color {
{ r, g, b }
}
///|
fn channel_luminance(value : Int) -> Double {
let normalized = value.to_double() / 255.0
if normalized <= 0.03928 {
normalized / 12.92
} else {
@math.pow((normalized + 0.055) / 1.055, 2.4)
}
}
///|
fn choose_suggestion(background : Color, required : Double) -> Suggestion {
let black = unchecked_color(0, 0, 0)
let white = unchecked_color(255, 255, 255)
if contrast_ratio(black, background) >= required {
SuggestUseBlack
} else if contrast_ratio(white, background) >= required {
SuggestUseWhite
} else {
SuggestIncreaseSeparation
}
}
///|
fn component_to_hex(value : Int) -> String {
let hex = value.to_string(radix=16).to_upper()
if hex.length() == 1 {
"0" + hex
} else {
hex
}
}
///|
fn format_ratio(value : Double) -> String {
let scaled = @math.round(value * 100.0).to_int()
let whole = scaled / 100
let fraction = scaled % 100
if fraction < 10 {
whole.to_string() + ".0" + fraction.to_string()
} else {
whole.to_string() + "." + fraction.to_string()
}
}
///|
fn char_at(text : String, index : Int) -> Char {
match text.get_char(index) {
Some(ch) => ch
None => abort("PaletteGuard internal string index out of bounds")
}
}
///|
fn is_rgb_function(token : String) -> Bool {
token.length() >= 5 &&
token[:4].to_owned() == "rgb(" &&
token[token.length() - 1:].to_owned() == ")"
}
///|
fn parse_hex_color(token : String) -> ColorParse {
let body = token[1:].to_owned()
match body.length() {
3 => parse_short_hex(body)
6 => parse_long_hex(body)
n => ColorParseFailed(InvalidHexLength(n))
}
}
///|
fn parse_short_hex(body : String) -> ColorParse {
match read_hex_digit(char_at(body, 0)) {
ComponentProblem(error) => ColorParseFailed(error)
ComponentValue(r) =>
match read_hex_digit(char_at(body, 1)) {
ComponentProblem(error) => ColorParseFailed(error)
ComponentValue(g) =>
match read_hex_digit(char_at(body, 2)) {
ComponentProblem(error) => ColorParseFailed(error)
ComponentValue(b) =>
ColorParsed(unchecked_color(r * 17, g * 17, b * 17))
}
}
}
}
///|
fn parse_long_hex(body : String) -> ColorParse {
match read_hex_byte(char_at(body, 0), char_at(body, 1)) {
ComponentProblem(error) => ColorParseFailed(error)
ComponentValue(r) =>
match read_hex_byte(char_at(body, 2), char_at(body, 3)) {
ComponentProblem(error) => ColorParseFailed(error)
ComponentValue(g) =>
match read_hex_byte(char_at(body, 4), char_at(body, 5)) {
ComponentProblem(error) => ColorParseFailed(error)
ComponentValue(b) => ColorParsed(unchecked_color(r, g, b))
}
}
}
}
///|
fn read_hex_byte(high : Char, low : Char) -> ComponentRead {
match read_hex_digit(high) {
ComponentProblem(error) => ComponentProblem(error)
ComponentValue(h) =>
match read_hex_digit(low) {
ComponentProblem(error) => ComponentProblem(error)
ComponentValue(l) => ComponentValue(h * 16 + l)
}
}
}
///|
fn read_hex_digit(ch : Char) -> ComponentRead {
let code = ch.to_uint().reinterpret_as_int()
if code >= 48 && code <= 57 {
ComponentValue(code - 48)
} else if code >= 97 && code <= 102 {
ComponentValue(code - 87)
} else if code >= 65 && code <= 70 {
ComponentValue(code - 55)
} else {
ComponentProblem(InvalidHexDigit(ch))
}
}
///|
fn parse_rgb_function(token : String) -> ColorParse {
let inner = token[4:token.length() - 1].to_owned()
let parts = [ for part in inner.split(",") => part.trim().to_owned() ]
if parts.length() != 3 {
return ColorParseFailed(InvalidRgbFunction(token))
}
match read_component("red", parts[0]) {
ComponentProblem(error) => ColorParseFailed(error)
ComponentValue(r) =>
match read_component("green", parts[1]) {
ComponentProblem(error) => ColorParseFailed(error)
ComponentValue(g) =>
match read_component("blue", parts[2]) {
ComponentProblem(error) => ColorParseFailed(error)
ComponentValue(b) => ColorParsed(unchecked_color(r, g, b))
}
}
}
}
///|
fn read_component(label : String, text : String) -> ComponentRead {
match parse_decimal(text) {
None => ComponentProblem(InvalidRgbFunction(text))
Some(value) =>
if in_channel(value) {
ComponentValue(value)
} else {
ComponentProblem(ComponentOutOfRange(label, value))
}
}
}
///|
fn parse_decimal(text : String) -> Int? {
let token = text.trim().to_owned()
if token.length() == 0 {
return None
}
let max_safe = 1000000
let mut value = 0
for ch in token.iter() {
let code = ch.to_uint().reinterpret_as_int()
if code < 48 || code > 57 {
return None
}
if value > max_safe {
return Some(max_safe + 1)
}
let next = value * 10 + code - 48
if next > max_safe {
// Preserve normal out-of-range values while bounding untrusted input.
return Some(max_safe + 1)
}
value = next
}
Some(value)
}
///|
fn parse_named_color(token : String) -> ColorParse {
match token {
"black" => ColorParsed(unchecked_color(0, 0, 0))
"white" => ColorParsed(unchecked_color(255, 255, 255))
"red" => ColorParsed(unchecked_color(255, 0, 0))
"green" => ColorParsed(unchecked_color(0, 128, 0))
"blue" => ColorParsed(unchecked_color(0, 0, 255))
"transparent" => ColorParseFailed(UnknownColorName(token))
_ => ColorParseFailed(UnknownColorName(token))
}
}