算法艺术创作
algorithmic-art
通过代码和可调参数创作生成艺术与交互式视觉效果。
contrast-check【设计与创意】Calculates the WCAG 2.x contrast ratio between two colors, grades it against the AA and AAA thresholds for body text, large text and non-text UI parts, and finds the closest shade that passes. Use when someone asks to "check the contrast", "is this text readable on this background", "does our palette meet WCAG AA", "fix the low-contrast text", "audit…
独立 GitHub 快照 · 自动分类 · 以原作者文档为准
WCAG measures contrast as (L1 + 0.05) / (L2 + 0.05), where L1 and L2 are the relative luminance of the lighter and the darker color. The result runs from 1:1 (same color) to 21:1 (black on white). This skill gives the agent a calculator with no dependencies, the threshold that applies to each kind of element, a way to collect the pairs that really meet on screen, and a repair step that returns the nearest passing shade instead of "make it darker".
A contrast check is about two colors that touch: text and the surface directly behind it, an icon and its button, a border and the page. Before computing anything:
@theme block or config, a tokens JSON file. Search for the token names to see which ones are used together.When a color is written as oklch(), hsl(), color-mix() or a keyword, let the browser resolve it. Computed hsl() and keyword colors already come back as rgb(), but Chrome returns oklch() as written and color-mix() as color(srgb …), so paint the value on a canvas and read the pixel. Run this in the DevTools console with the element selected:
const ctx = Object.assign(document.createElement('canvas'), { width: 1, height: 1 }).getContext('2d');
const resolve = (css) => {
ctx.clearRect(0, 0, 1, 1); ctx.fillStyle = css; ctx.fillRect(0, 0, 1, 1);
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
return `rgba(${r}, ${g}, ${b}, ${+(a / 255).toFixed(3)})`;
};
const style = getComputedStyle($0);
[resolve(style.color), resolve(style.backgroundColor), style.fontSize, style.fontWeight];
oklch(0.7 0.15 250) comes back as rgba(75, 163, 247, 1). The read-back is exact only for opaque colors (canvas pixels are stored premultiplied, so rgba(15, 23, 42, 0.1) returns as rgba(20, 20, 39, 0.102)): when the computed value is already rgba() with an alpha below 1, use it as it is. A background of rgba(0, 0, 0, 0) means the element is transparent: walk up to the first ancestor that paints one.
| What is checked | AA | AAA | Criterion |
|---|---|---|---|
| Body text: under 24 px, or under 18.66 px when bold | 4.5 | 7 | 1.4.3, 1.4.6 |
| Large text: 24 px (18 pt) and up, or 18.66 px (14 pt) bold and up | 3 | 4.5 | 1.4.3, 1.4.6 |
| What identifies a control or its state (input border, checkbox tick, toggle), icons, chart parts needed to read the chart | 3 | — | 1.4.11 |
| Focus indicator, the same pixels focused against unfocused | — | 3 | 2.4.13 |
Sizes are CSS pixels as delivered, before any user zoom. Treat a font-weight of 700 or more as bold (700 is what the CSS keyword bold means).
Save as contrast.py:
#!/usr/bin/env python3
"""WCAG 2.x contrast. Pair: contrast.py TEXT BACKGROUND [--need 4.5]. Palette: contrast.py --palette C1 C2 C3 ..."""
import colorsys, json, math, re, sys
def parse(text):
"""'#rgb', '#rrggbb', '#rrggbbaa', 'rgb(31 41 55)', 'rgba(31, 41, 55, 0.6)' -> (r, g, b, alpha), channels 0-255."""
c = text.strip().lower()
m = re.fullmatch(r'#?([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})', c)
if m:
h = m[1] if len(m[1]) > 4 else ''.join(ch * 2 for ch in m[1])
v = [int(h[i:i + 2], 16) for i in range(0, len(h), 2)]
return (*v[:3], v[3] / 255 if len(v) == 4 else 1.0)
m = re.fullmatch(r'rgba?\(([^)]+)\)', c)
if m:
p = [x for x in re.split(r'[\s,/]+', m[1].strip()) if x]
if len(p) in (3, 4) and not any(x.endswith('%') for x in p[:3]):
alpha = 1.0 if len(p) == 3 else float(p[3].rstrip('%')) / (100 if p[3].endswith('%') else 1)
return (*(float(x) for x in p[:3]), alpha)
raise SystemExit(f'cannot read color "{text}": pass hex or rgb()/rgba() with 0-255 channels')
def over(top, below):
"""The opaque color the screen shows when `top` (with alpha) is painted on `below`."""
a = top[3]
return tuple(round(top[i] * a + below[i] * (1 - a)) for i in range(3)) + (1.0,)
def luminance(color):
def linear(v):
s = v / 255
return s / 12.92 if s <= 0.04045 else ((s + 0.055) / 1.055) ** 2.4
r, g, b = (linear(v) for v in color[:3])
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def ratio(a, b):
lighter, darker = sorted((luminance(a), luminance(b)), reverse=True)
return (lighter + 0.05) / (darker + 0.05)
def to_hex(color):
return '#%02x%02x%02x' % tuple(round(v) for v in color[:3])
def shown(r):
return math.floor(r * 100) / 100 # cut, never round up: 4.499 must not print as 4.5
def nearest_passing(color, fixed, need):
"""`color` with hue and saturation kept, lightness pushed away from `fixed` just far enough to reach `need`."""
h, l, s = colorsys.rgb_to_hls(*(v / 255 for v in color[:3]))
at = lambda light: tuple(round(v * 255) for v in colorsys.hls_to_rgb(h, light, s))
passes = 0.0 if luminance(color) <= luminance(fixed) else 1.0
if ratio(at(passes), fixed) < need:
return None # black or white on this side is still short: the other color has to move
fails = l
for _ in range(24):
mid = (fails + passes) / 2
if ratio(at(mid), fixed) >= need: passes = mid
else: fails = mid
return to_hex(at(passes))
def check(text, background, need=None, canvas='#ffffff'):
bg = over(parse(background), parse(canvas))
fg = over(parse(text), bg)
r = ratio(fg, bg)
out = {'text': to_hex(fg), 'background': to_hex(bg), 'ratio': shown(r),
'body_text': 'AAA' if r >= 7 else 'AA' if r >= 4.5 else 'fail',
'large_text': 'AAA' if r >= 4.5 else 'AA' if r >= 3 else 'fail',
'non_text': 'AA' if r >= 3 else 'fail'}
if need and r < need:
out['fix'] = {'need': need, 'text_instead': nearest_passing(fg, bg, need),
'background_instead': nearest_passing(bg, fg, need)}
return out, r
if __name__ == '__main__':
args = sys.argv[1:]
if args and args[0] == '--palette':
colors = args[1:]
pairs = [(check(a, b)[1], a, b) for i, a in enumerate(colors) for b in colors[i + 1:]]
for r, a, b in sorted(pairs, reverse=True):
use = ('body text AAA' if r >= 7 else 'body text AA' if r >= 4.5 else
'large text and UI parts only' if r >= 3 else 'fail')
print(f'{a} + {b} {shown(r):5.2f} {use}')
sys.exit(0)
need = float(args[args.index('--need') + 1]) if '--need' in args else None
result, r = check(args[0], args[1], need)
print(json.dumps(result, indent=2))
sys.exit(1 if need and r < need else 0)
Confirm the copy before trusting it: python3 contrast.py '#000000' '#ffffff' must print "ratio": 21.0, '#777777' on '#ffffff' must print 4.47 (a fail) and '#767676' must print 4.54 (a pass).
canvas to check() when the page is not white). The text and background fields in the output are the opaque colors that were actually compared.--need takes the threshold from step 2, adds a fix block on failure and makes the exit code 1.--palette prints every unordered pairing once, best first, with what that pairing is good for. It is a map of what may be combined, not a list of failures: two colors that never touch do not need contrast.fix.text_instead and fix.background_instead keep the hue and saturation and move lightness the smallest distance that passes. null means that side cannot get there by moving further from the other color (white text cannot get lighter), so the other side has to change. Both null means neither color alone can reach the target: change the pairing. When a brand color may not change, keep it for large text and UI parts and add a darker text variant as its own token. After changing a token, re-run every pair that uses it, in every theme.
Report in this shape, worst first, and state what was not checked:
| Pair | Colors | Ratio | Needs | Result | Change |
|-----------------------|--------------------|-------|-------|--------|------------------------|
| Label on amber button | #ffffff on #f59e0b | 2.14 | 4.5 | fail | label to #1c1917 (8.14)|
| Muted text on card | #94a3b8 on #f8fafc | 2.45 | 4.5 | fail | text to #607591 (4.50) |
| Body text on page | #0f172a on #f8fafc | 17.06 | 4.5 | AAA | — |
Not checked: text over the hero photo (needs pixel sampling), disabled buttons (exempt).
Marta Lindqvist's audit of the Harbor Books storefront reports "insufficient contrast" on the grey helper text and on the amber "Add to basket" button. The stylesheet has --muted: #94a3b8, --surface: #f8fafc, --accent: #f59e0b, and the button label is white at 16 px, weight 600, so both are body text and need 4.5.
python3 contrast.py '#94a3b8' '#f8fafc' --need 4.5
python3 contrast.py '#ffffff' '#f59e0b' --need 4.5
Both exit with 1. The output, with line breaks condensed:
{ "text": "#94a3b8", "background": "#f8fafc", "ratio": 2.45, "body_text": "fail", "large_text": "fail", "non_text": "fail",
"fix": { "need": 4.5, "text_instead": "#607591", "background_instead": null } }
{ "text": "#ffffff", "background": "#f59e0b", "ratio": 2.14, "body_text": "fail", "large_text": "fail", "non_text": "fail",
"fix": { "need": 4.5, "text_instead": null, "background_instead": "#a56a07" } }
The helper text becomes #607591, which also clears the white cards (4.71). For the button, darkening the amber to #a56a07 would change the brand color, so the label changes instead: python3 contrast.py '#1c1917' '#f59e0b' gives 8.14. The agent edits --muted, sets the button label to #1c1917, and reports both rows as in the table above.
A dashboard for the logistics firm Tidewater Freight uses near-white text (#f8fafc) at reduced opacity on #0f172a. Opacity changes the color that reaches the eye, so the rgba value goes in as written:
python3 contrast.py 'rgba(248, 250, 252, 0.6)' '#0f172a' # text #9b9fa8, ratio 6.73, body text AA
python3 contrast.py 'rgba(248, 250, 252, 0.38)' '#0f172a' --need 4.5 # text #686d7a, ratio 3.44, fail
The 38% step fails as body text; its fix.text_instead is #7a808e. To keep the theme from regressing, the pairs go into contrast-pairs.txt (text, background, threshold, label):
#e2e8f0 #0f172a 4.5 body-text-dark
#64748b #0f172a 4.5 placeholder-dark
#38bdf8 #0f172a 4.5 link-dark
#334155 #0f172a 3 input-border-dark
failed=0
while read -r text background need label; do
python3 contrast.py "$text" "$background" --need "$need" > /dev/null \
|| { echo "FAIL $label: $text on $background needs $need"; failed=1; }
done < contrast-pairs.txt
exit "$failed"
FAIL placeholder-dark: #64748b on #0f172a needs 4.5
FAIL input-border-dark: #334155 on #0f172a needs 3
The script exits 1, so the pipeline stops. The placeholder (3.75) moves to #718198 and the input border (1.72) to #506585, the values the fix block returns for each.
opacity on an ancestor fades the text as well. Multiply it into the alpha before checking.image-analysis skill does this) and check the worst one, or put a solid scrim behind the text.display-p3 color outside the sRGB range is clipped by the canvas conversion, so treat a ratio near the limit as uncertain.accessibility-auditor skill.