Forms

ColorContrast

Checks two colours against WCAG 2 for normal and large text, and finds the nearest colour that passes with the same hue and saturation.

#smart#interactive#colour#accessibility#wcag#contrast

Demo

Preview

Colour contrast

The quick brown fox jumps over the lazy dog

The quick brown fox jumps over the lazy dog

4.47:1 contrast ratio

  • AA failsNormal text, 4.5:1
  • AAA failsNormal text, 7:1
  • AA passesLarge text, 3:1
  • AAA failsLarge text, 4.5:1
  • AA, text:#7676764.54:1
  • AA, background:#0606064.52:1
  • AAA, text:#5959597.00:1
  • AAA, background:No shade of this background colour reaches 7:1 here.

Warning banner

Your card expires this month

Your card expires this month

2.52:1 contrast ratio

  • AA failsNormal text, 4.5:1
  • AAA failsNormal text, 7:1
  • AA failsLarge text, 3:1
  • AAA failsLarge text, 4.5:1
  • AA, text:#af5c094.52:1
  • AA, background:#4931004.53:1
  • AAA, text:#8344077.04:1
  • AAA, background:#1810007.02:1
  • AA for large text, text:#dc740c3.00:1
  • AA for large text, background:#6c49003.01:1

Change a colour to see on_change.

Get it

It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.

$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/color_contrast.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/color_contrast.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/color_contrast.qwe
component ColorContrastDemoLive() {    state said = "Change a colour to see on_change."    <div.demo-stack>        <ColorContrast id="color-contrast-demo-live" title="Warning banner" name="banner" foreground="hsl(30, 90%, 50%)" background="#fff7e6"                       sample="Your card expires this month"                       on_change={(r) => { said = r == null ? "on_change: not a colour." : `on_change: ${r.foreground} on ${r.background}, AA ${r.aa ? "passes" : "fails"}.` }}/>        <p.demo-note>{said}</p>    </div>}view ColorContrastDemo() =    <div.demo-stack>        <ColorContrast id="color-contrast-demo" foreground="#777777" background="#ffffff"/>        <ColorContrastDemoLive/>    </div>

Source

ui/color_contrast.qwe
// ColorContrast: two colours and what WCAG 2 says of them: the contrast ratio, AA// and AAA for normal and large text, a live sample, and, for each level that fails,// the nearest colour that passes with the same hue and saturation, one click to use.// Colours are written as hex (#36c, #3366cc), rgb(51, 102, 204), rgb(51 102 204),// rgb(20% 40% 80%), or hsl(220, 60%, 50%); anything else, and anything see-through// (the answer would depend on what is behind it), is refused with the reason.////   import ui/color_contrast.qwe use ColorContrast////   <ColorContrast foreground="#777777" background="#ffffff"/>//   <ColorContrast id="brand" foreground={ink} background="hsl(40, 90%, 96%)"//                  on_change={(r) => { passes = r != null && r.aa }}/>//// foreground, background: where the two fields start, as any form above. id: a// prefix for its ids ("color-contrast"), unique on the page. title: the heading// ("Colour contrast"). sample: the sample's words. name: a prefix for the fields'// names ("brand" names them brand_foreground, brand_background; left out, foreground// and background). class: more classes for its outer element.// on_change(result): inside one of your components, called after each change with// { foreground, background, ratio, aa, aaa, aa_large, aaa_large } (hex, the ratio as a// float, passes as booleans), or null while a colour is not valid.//// A level is judged on the exact ratio: 4.499 fails AA, so the ratio shown is cut, not// rounded, to two decimals and never shows 4.50 for a colour that fails. The formula// is WCAG 2.2's; the rest of the file works on 8-bit colours, as a stylesheet does.//// For a program's own use: color_contrast_parse(text) gives { r, g, b, hex } or// { error }; color_contrast_ratio(a, b), the ratio of two parsed colours;// color_contrast_levels(ratio), the four passes; color_contrast_nearest(color, other,// target), the nearest passing colour with its hue and saturation, or null.import ui/theme.qwe use ui_clslet color_contrast_css = css`.ui-ccontrast { display: grid; gap: 16px; min-width: 0; max-width: 640px; padding: 20px 22px; border: 1px solid var(--ui-border);  border-radius: var(--ui-radius); background: var(--ui-surface); color: var(--ui-fg); font: 14px/1.45 var(--ui-font); }.ui-ccontrast .ui-ccontrast-title { margin: 0; font-size: 16px; font-weight: 650; }.ui-ccontrast-fields { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: start; gap: 12px; }.ui-ccontrast-field { display: grid; gap: 5px; min-width: 0; }.ui-ccontrast-field label { font-size: 13px; font-weight: 500; }.ui-ccontrast-box { box-sizing: border-box; display: flex; align-items: center; gap: 8px; height: 38px; padding: 0 10px; border: 1px solid var(--ui-border);  border-radius: var(--ui-radius-sm); background: var(--ui-surface); transition: border-color .15s, box-shadow .15s; }.ui-ccontrast-box:hover { border-color: var(--ui-strong); }.ui-ccontrast-box:focus-within { border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-ccontrast-box input[type=text] { flex: 1; width: 0; min-width: 0; height: 100%; padding: 0; border: 0; outline: none; background: transparent;  color: var(--ui-fg); font: 13px var(--ui-mono); }.ui-ccontrast-box input[type=color] { flex: none; width: 22px; height: 22px; padding: 0; border: 1px solid var(--ui-border); border-radius: 4px; background: none; cursor: pointer; }.ui-ccontrast-box input[type=color]::-webkit-color-swatch-wrapper { padding: 0; }.ui-ccontrast-box input[type=color]::-webkit-color-swatch { border: 0; }.ui-ccontrast-bad .ui-ccontrast-box { border-color: var(--ui-danger); }.ui-ccontrast-why { margin: 0; color: var(--ui-danger); font-size: 13px; overflow-wrap: anywhere; }.ui-ccontrast-swap { align-self: start; margin-top: 24px; height: 38px; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm);  background: var(--ui-surface); color: var(--ui-fg); font: 500 13px/1 var(--ui-font); cursor: pointer; }.ui-ccontrast-swap:hover { border-color: var(--ui-strong); background: var(--ui-subtle); }.ui-ccontrast-swap:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-ccontrast-sample { display: grid; gap: 4px; padding: 14px 16px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); overflow-wrap: anywhere; }.ui-ccontrast-sample p { margin: 0; }.ui-ccontrast-sample-large { font-size: 24px; line-height: 1.25; }.ui-ccontrast-ratio { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; margin: 0; font-size: 13px; color: var(--ui-muted); }.ui-ccontrast-ratio strong { color: var(--ui-fg); font-size: 26px; font-weight: 700; font-variant-numeric: tabular-nums; }.ui-ccontrast-levels { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 8px; margin: 0; padding: 0; list-style: none; }.ui-ccontrast-level { display: grid; gap: 1px; padding: 8px 10px; border-radius: var(--ui-radius-sm); background: var(--ui-danger-soft); color: var(--ui-danger); font-size: 12px; }.ui-ccontrast-level b { font-size: 13px; }.ui-ccontrast-pass { background: var(--ui-success-soft); color: var(--ui-success); }.ui-ccontrast-fixes { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }.ui-ccontrast-fix { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; font-size: 13px; }.ui-ccontrast-chip { flex: none; width: 16px; height: 16px; border: 1px solid var(--ui-border); border-radius: 4px; }.ui-ccontrast-fix code { font: 13px var(--ui-mono); }.ui-ccontrast-fix small { color: var(--ui-muted); font-size: 12px; }.ui-ccontrast-use { height: 28px; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface);  color: var(--ui-fg); font: 500 12px/1 var(--ui-font); cursor: pointer; }.ui-ccontrast-use:hover { border-color: var(--ui-accent); color: var(--ui-accent); }.ui-ccontrast-use:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-ccontrast-note { margin: 0; color: var(--ui-muted); font-size: 13px; }@media (max-width: 480px) { .ui-ccontrast-fields { grid-template-columns: minmax(0, 1fr); } .ui-ccontrast-swap { margin-top: 0; justify-self: start; } }@media (prefers-reduced-motion: reduce) { .ui-ccontrast-box { transition: none; } }`// A byte as two hex digits.fn color_contrast_byte(n: int) -> str {    let digits = "0123456789abcdef"    digits[floor(n / 16)] + digits[n % 16]}fn color_contrast_of(r: int, g: int, b: int) -> map =    { r, g, b, hex: "#" + color_contrast_byte(r) + color_contrast_byte(g) + color_contrast_byte(b) }// The value of hex digits: "ff" is 255.fn color_contrast_from_hex(s: str) -> int =    reduce(split(s, ""), 0, (n, c) => n * 16 + index_of("0123456789abcdef", c))// The numbers in rgb(...) or hsl(...): by commas or by spaces, and what follows a// slash, the alpha, apart. A part that is not a number stays as text, for the message.fn color_contrast_parts(inner: str) -> map {    let halves = split(inner, "/")    let main = trim(halves[0])    let raw = contains(main, ",") ? split(main, ",") | map(trim) : re.split(main, "\\s+")    { parts: raw, alpha: len(halves) > 1 ? trim(halves[1]) : null, slashes: len(halves) - 1 }}// A CSS number or percentage as a float, or null. "1e3" and "+5" are CSS too.fn color_contrast_num(s: str) {    if !re.test(s, "^[+-]?(\\d+\\.?\\d*|\\.\\d+)(e[+-]?\\d+)?$") { return null }    let f = float(s)    is_err(f) ? null : f}// Whether an alpha means fully opaque.fn color_contrast_opaque(a: str) -> bool {    let pct = ends_with(a, "%")    let n = color_contrast_num(pct ? slice(a, 0, len(a) - 1) : a)    n != null && n == (pct ? 100 : 1)}let color_contrast_clear = "A see-through colour has no contrast of its own: give a solid one."fn color_contrast_hex_text(t: str) -> map {    let h = starts_with(t, "#") ? slice(t, 1, len(t)) : t    if !re.test(h, "^[0-9a-f]*$") { return { error: `"${t}" has something other than the hex digits 0 to 9 and a to f.` } }    let n = len(h)    if n != 3 && n != 4 && n != 6 && n != 8 { return { error: `A hex colour has 3 or 6 digits; "${t}" has ${n}.` } }    let wide = n == 3 || n == 4 ? join(split(h, "") | map((c) => c + c), "") : h    if len(wide) == 8 && slice(wide, 6, 8) != "ff" { return { error: color_contrast_clear } }    color_contrast_of(color_contrast_from_hex(slice(wide, 0, 2)), color_contrast_from_hex(slice(wide, 2, 4)), color_contrast_from_hex(slice(wide, 4, 6)))}fn color_contrast_rgb_text(inner: str) -> map {    let p = color_contrast_parts(inner)    let bare = len(p.parts) == 4 && p.alpha == null ? take(p.parts, 3) : p.parts    let alpha = len(p.parts) == 4 && p.alpha == null ? p.parts[3] : p.alpha    if len(bare) != 3 || p.slashes > 1 { return { error: `rgb() takes three numbers; this has ${len(bare)}.` } }    if alpha != null && !color_contrast_opaque(alpha) { return { error: color_contrast_clear } }    let vals = bare | map((s) => {        let pct = ends_with(s, "%")        let n = color_contrast_num(pct ? slice(s, 0, len(s) - 1) : s)        if n == null { return { why: `"${s}" is not a number.` } }        if n < 0 || n > (pct ? 100 : 255) { return { why: `${s} is outside 0 to ${pct ? "100%" : "255"}.` } }        { v: round(pct ? n * 255 / 100 : n) }    })    let bad = find(vals, (v) => v.why != null)    if bad != null { return { error: bad.why } }    color_contrast_of(vals[0].v, vals[1].v, vals[2].v)}fn color_contrast_hsl_text(inner: str) -> map {    let p = color_contrast_parts(inner)    let bare = len(p.parts) == 4 && p.alpha == null ? take(p.parts, 3) : p.parts    let alpha = len(p.parts) == 4 && p.alpha == null ? p.parts[3] : p.alpha    if len(bare) != 3 || p.slashes > 1 { return { error: `hsl() takes a hue, a saturation and a lightness; this has ${len(bare)}.` } }    if alpha != null && !color_contrast_opaque(alpha) { return { error: color_contrast_clear } }    let hue = color_contrast_num(re.replace(bare[0], "deg$", ""))    if hue == null { return { error: `"${bare[0]}" is not a hue: give degrees, such as 220.` } }    let pcts = [bare[1], bare[2]] | map((s) => color_contrast_num(re.replace(s, "%$", "")))    if pcts[0] == null || pcts[1] == null { return { error: `Saturation and lightness are percentages; got "${bare[1]}" and "${bare[2]}".` } }    if pcts[0] < 0 || pcts[0] > 100 || pcts[1] < 0 || pcts[1] > 100 { return { error: "Saturation and lightness go from 0% to 100%." } }    if abs(hue) > 1000000 { return { error: `A hue of ${bare[0]} is too large to be meant.` } }    color_contrast_from_hsl(hue, pcts[0] / 100, pcts[1] / 100)}// A colour from what was typed: { r, g, b, hex }, or { error } saying what is wrong.fn color_contrast_parse(text) -> map {    let t = lower(trim(type_of(text) == "str" ? text : ""))    if t == "" { return { error: "Enter a colour, such as #3366cc." } }    if len(t) > 80 { return { error: "That is too long to be a colour." } }    let fn_call = re.find(t, "^([a-z]+)\\((.*)\\)$")    if fn_call != null {        let word = fn_call.groups[0]        if word == "rgb" || word == "rgba" { return color_contrast_rgb_text(fn_call.groups[1]) }        if word == "hsl" || word == "hsla" { return color_contrast_hsl_text(fn_call.groups[1]) }        return { error: `${word}() is not taken: use hex, rgb() or hsl().` }    }    if starts_with(t, "#") || re.test(t, "^[0-9a-f]+$") { return color_contrast_hex_text(t) }    if re.test(t, "^(rgb|hsl)a?\\(") { return { error: "A bracket is missing at the end." } }    { error: `"${trim(text)}" is not a colour: use hex, rgb() or hsl().` }}// A colour from hue (degrees, any), saturation and lightness (0 to 1).fn color_contrast_from_hsl(h, s, l) -> map {    let c = (1 - abs(2 * l - 1)) * s    let hue = ((h % 360) + 360) % 360    let x = hue / 60    let m = l - c / 2    let channel = (n) => {        let k = (n + x) % 6        let f = max([0, min([k, 4 - k, 1])])        round((m + c - c * f) * 255)    }    color_contrast_of(channel(5), channel(3), channel(1))}// { h (degrees), s, l (0 to 1) } of a colour.fn color_contrast_hsl(c: map) -> map {    let r = c.r / 255    let g = c.g / 255    let b = c.b / 255    let top = max([r, g, b])    let low = min([r, g, b])    let d = top - low    let l = (top + low) / 2    if d == 0 { return { h: 0, s: 0, l } }    let s = d / (1 - abs(2 * l - 1))    let h = top == r ? 60 * (((g - b) / d + 6) % 6) : (top == g ? 60 * ((b - r) / d + 2) : 60 * ((r - g) / d + 4))    { h, s, l }}// WCAG's relative luminance of a colour.fn color_contrast_luminance(c: map) -> float {    let line = (v: int) => {        let s = v / 255        s <= 0.04045 ? s / 12.92 : pow((s + 0.055) / 1.055, 2.4)    }    0.2126 * line(c.r) + 0.7152 * line(c.g) + 0.0722 * line(c.b)}// The contrast ratio of two colours, 1 to 21.fn color_contrast_ratio(a: map, b: map) -> float {    let la = color_contrast_luminance(a)    let lb = color_contrast_luminance(b)    (max([la, lb]) + 0.05) / (min([la, lb]) + 0.05)}// A ratio cut to two decimals as text: 4.4999 is "4.49", so it never looks passed.fn color_contrast_ratio_text(ratio) -> str {    let hundredths = floor(ratio * 100 + 0.0000001)    let part = hundredths % 100    `${floor(hundredths / 100)}.${part < 10 ? "0" : ""}${part}`}fn color_contrast_levels(ratio) -> map =    { aa: ratio >= 4.5, aaa: ratio >= 7, aa_large: ratio >= 3, aaa_large: ratio >= 4.5 }// The lightest (or darkest) move of `color`'s lightness from `from` towards `to` that// reaches `target` on `other`, or null. Lightness moves the channels one way only, so// contrast with `other` only grows along the way once it has begun to, and halving// finds the edge; each try is judged as 8-bit colour, as it will be written.fn color_contrast_towards(h, s, from, to, other: map, target) {    let at = (l) => color_contrast_from_hsl(h, s, l)    let passes = (l) => color_contrast_ratio(at(l), other) >= target    if !passes(to) { return null }    let near = from    let far = to    let i = 0    while i < 16 {        let mid = (near + far) / 2        if passes(mid) { far = mid } else { near = mid }        i += 1    }    { color: at(far), l: far }}// The passing colour nearest `color` with its hue and saturation: { color, ratio,// moved (how far its lightness went, 0 to 1) }, or null when no lightness reaches// `target` on `other`.fn color_contrast_nearest(color: map, other: map, target) {    let hsl = color_contrast_hsl(color)    let tries = [color_contrast_towards(hsl.h, hsl.s, hsl.l, 0, other, target),                 color_contrast_towards(hsl.h, hsl.s, hsl.l, 1, other, target)]        | filter((t) => t != null)        | map((t) => { color: t.color, ratio: color_contrast_ratio(t.color, other), moved: abs(t.l - hsl.l) })    len(tries) == 0 ? null : first(sort_by(tries, (t) => t.moved))}// The levels that fail and what would pass them, for the text and for the background.fn color_contrast_fixes(fg: map, bg: map) -> list {    let ratio = color_contrast_ratio(fg, bg)    let wanted = [{ name: "AA", target: 4.5 }, { name: "AAA", target: 7 }, { name: "AA for large text", target: 3 }]    let open = wanted | filter((w) => ratio < w.target)    let row = (w, side: str, id: str, found) =>        { id: `${id}-${w.target}`, level: w.name, target: w.target, side, found, said: found != null ? `${color_contrast_ratio_text(found.ratio)}:1` : "" }    flatten(open | map((w) => [row(w, "text", "text", color_contrast_nearest(fg, bg, w.target)),                               row(w, "background", "back", color_contrast_nearest(bg, fg, w.target))]))}component ColorContrast(foreground: str = "#5b4bdb", background: str = "#ffffff", id: str = "color-contrast", title: str = "Colour contrast",                        sample: str = "The quick brown fox jumps over the lazy dog", name: str? = null, on_change = null, class: str? = null) {    style global color_contrast_css    state fg_text = foreground    state bg_text = background    let named = (n: str) => name != null ? `${name}_${n}` : n    let fg = color_contrast_parse(fg_text)    let bg = color_contrast_parse(bg_text)    let ok = fg.error == null && bg.error == null    let ratio = ok ? color_contrast_ratio(fg, bg) : 1    let levels = color_contrast_levels(ratio)    let fixes = ok ? color_contrast_fixes(fg, bg) : []    let rows = [        { id: "aa", name: "AA", size: "Normal text, 4.5:1", pass: levels.aa },        { id: "aaa", name: "AAA", size: "Normal text, 7:1", pass: levels.aaa },        { id: "aa-large", name: "AA", size: "Large text, 3:1", pass: levels.aa_large },        { id: "aaa-large", name: "AAA", size: "Large text, 4.5:1", pass: levels.aaa_large },    ]    let tell = () => {        if on_change != null {            let a = color_contrast_parse(fg_text)            let b = color_contrast_parse(bg_text)            if a.error != null || b.error != null {                on_change(null)            } else {                let r = color_contrast_ratio(a, b)                on_change({ foreground: a.hex, background: b.hex, ratio: r, ...color_contrast_levels(r) })            }        }    }    <section class={ui_cls(["ui-ccontrast", class])} id={id} aria-labelledby={`${id}-title`}>        <h3.ui-ccontrast-title id={`${id}-title`}>{title}</h3>        <div.ui-ccontrast-fields>            <div class={ui_cls(["ui-ccontrast-field", fg.error != null && "ui-ccontrast-bad"])}>                <label for={`${id}-fg`}>Text colour</label>                <div.ui-ccontrast-box>                    <input type="color" tabindex="-1" aria-hidden="true" value={fg.hex ?? "#000000"} @input={(e) => { fg_text = e.value; tell() }}/>                    <input type="text" id={`${id}-fg`} name={named("foreground")} value={fg_text} autocomplete="off" spellcheck="false"                           aria-invalid={fg.error != null ? "true" : null} aria-describedby={fg.error != null ? `${id}-fg-why` : null}                           @input={(e) => { fg_text = e.value; tell() }}/>                </div>                {fg.error != null && <p.ui-ccontrast-why id={`${id}-fg-why`} role="alert">{fg.error}</p>}            </div>            <button.ui-ccontrast-swap type="button" aria-label="Swap the two colours"                    @click={() => {                        let t = fg_text                        fg_text = bg_text                        bg_text = t                        tell()                    }}>Swap</button>            <div class={ui_cls(["ui-ccontrast-field", bg.error != null && "ui-ccontrast-bad"])}>                <label for={`${id}-bg`}>Background</label>                <div.ui-ccontrast-box>                    <input type="color" tabindex="-1" aria-hidden="true" value={bg.hex ?? "#ffffff"} @input={(e) => { bg_text = e.value; tell() }}/>                    <input type="text" id={`${id}-bg`} name={named("background")} value={bg_text} autocomplete="off" spellcheck="false"                           aria-invalid={bg.error != null ? "true" : null} aria-describedby={bg.error != null ? `${id}-bg-why` : null}                           @input={(e) => { bg_text = e.value; tell() }}/>                </div>                {bg.error != null && <p.ui-ccontrast-why id={`${id}-bg-why`} role="alert">{bg.error}</p>}            </div>        </div>        {ok && <div.ui-ccontrast-sample style={`color: ${fg.hex}; background: ${bg.hex}`}>            <p.ui-ccontrast-sample-large>{sample}</p>            <p>{sample}</p>        </div>}        {ok && <p.ui-ccontrast-ratio aria-live="polite"><strong>{`${color_contrast_ratio_text(ratio)}:1`}</strong> <span>contrast ratio</span></p>}        {ok && <ul.ui-ccontrast-levels>            {for r in rows => <li key={r.id} class={ui_cls(["ui-ccontrast-level", r.pass && "ui-ccontrast-pass"])} data-level={r.id}>                <b>{`${r.name} ${r.pass ? "passes" : "fails"}`}</b><span>{r.size}</span>            </li>}        </ul>}        {ok && len(fixes) > 0 && <ul.ui-ccontrast-fixes>            {for f in fixes => <li.ui-ccontrast-fix key={f.id} data-fix={f.id}>                {f.found != null                    ? <span.ui-ccontrast-chip aria-hidden="true" style={`background: ${f.found.color.hex}`}></span>                    : null}                <span>{`${f.level}, ${f.side}:`}</span>                {f.found != null ? <code>{f.found.color.hex}</code> : <small>{`No shade of this ${f.side} colour reaches ${f.target}:1 here.`}</small>}                {f.found != null && <small>{f.said}</small>}                {f.found != null && <button.ui-ccontrast-use type="button" aria-label={`Use ${f.found.color.hex} as the ${f.side} colour`}                        @click={() => {                            if f.side == "text" { fg_text = f.found.color.hex } else { bg_text = f.found.color.hex }                            tell()                        }}>Use it</button>}            </li>}        </ul>}        {ok && len(fixes) == 0 && <p.ui-ccontrast-note>Passes every level: nothing to change.</p>}    </section>}
Thread
35Useful? Like it, or add to its thread below.

Thread 0 posts

On ColorContrast: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.