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.
Demo
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.qwemain.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>}Related
PaletteBuilderBuilds a 50 to 950 scale and a semantic set from one colour, shows which pairs pass contrast, and exports CSS variables or JSON.3 views · 6 downloadsAmountInputReads 1.5k, 2m, 1,234.50, 1.234,50, $12 and EUR 40, decides which mark groups, shows the normalised amount, flags what could go two ways, and sends a real decimal.3 views · 8 downloadsAutosaveFormKeeps a draft in the browser as it is typed, offers to restore or discard it, says when saving failed, and works with storage blocked.3 views · 5 downloadsBarcodeInputTells EAN-13, EAN-8, UPC-A, ISBN-10 and ISBN-13 apart by shape, checks the check digit, says what the right one would be, and converts ISBN-10 to ISBN-13.2 views · 5 downloadsBranchingSurveyOne question at a time with jumps by answer: back keeps answers, progress is counted from the longest path left, and unreachable or looping questions are reported.3 views · 6 downloadsContactPastePaste an email signature or a blurb: it pulls out the name, email, phone, website, company and address into editable fields, marks how sure each guess is, and never overwrites an edit.5 views · 6 downloads
Thread
Thread 0 posts
On ColorContrast: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.