Files
minstrel/web/scripts/check-tint-contrast.js
T
bvandeusenandClaude Opus 5.5 743b6f5eac
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m6s
release / go (push) Successful in 1m29s
release / integration (push) Successful in 4m30s
release / android (push) Successful in 5m18s
release / Build signed APK (releases and dev) (push) Successful in 5m32s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
fix(web): error text uses error-fg on every surface, not only on tints (#3150)
Raw error red fails AA as text even with no tint behind it: in dark mode
it measures 3.63:1 on obsidian, 3.23 on iron and 2.64 on slate, against
4.5. error-fg (the house formula, 50% toward parchment) measures 5.30 at
worst across both modes.

- All 19 text-error uses become text-error-fg: the "Couldn't load"
  messages on the admin pages, the integrations form errors, the flag
  popover, and the error toast's text. The toast keeps its error border,
  since a border is a graphic with a 3:1 floor.
- check-tint-contrast flags raw error text anywhere (text-error,
  class:text-error, color: var(--fs-error)) and leaves borders and
  outlines alone. Run against the files before the swap, it finds all 19.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:00:28 -04:00

111 lines
4.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Finds a hue painted as TEXT on a tint of itself (#3150). The tint sits close
// to the surface under it, so the raw hue on it measures 1.6–2.4:1 against
// AA's 4.5. The fix is the hue's -fg partner (tokens.json colors.fg), mixed
// toward parchment: accent-fg on an accent tint measures 5.03.
//
// Two spellings are checked:
// Tailwind one class string holding both `bg-X-tint` / `bg-X/NN` and `text-X`
// CSS one rule block holding both a `color-mix(… var(--fs-X) N%,
// transparent)` background and `color: var(--fs-X)`
//
// A pair split across two strings (a tint in one class: directive, the text
// in another) is not seen. The lookbehind on `color:` keeps border-color and
// outline-color out: a border is a graphic with a 3:1 floor, not text.
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
export const HUES = {
accent: 'accent-fg',
warning: 'warning-fg',
error: 'error-fg',
info: 'info-fg',
moss: 'success-fg'
};
// The quoted string around index i. Read outward from the tint class rather
// than pairing every quote in the file, which an apostrophe in prose
// ("Couldn't") throws out of step.
function enclosingString(source, i) {
const start = Math.max(...['"', "'", '`'].map((q) => source.lastIndexOf(q, i)));
if (start < 0) return '';
const end = source.indexOf(source[start], i);
return end < 0 ? '' : source.slice(start, end + 1);
}
/** Tailwind class strings that put text-X on a bg-X tint. */
export function findTailwind(source) {
const found = [];
for (const hue of Object.keys(HUES)) {
const tint = new RegExp(`(?<=^|[\\s'"\`])bg-${hue}(?:-tint|/\\d+)(?=$|[\\s'"\`])`, 'g');
const text = new RegExp(`(?:^|[\\s'"\`])text-${hue}(?=$|[\\s'"\`])`);
for (const m of source.matchAll(tint)) {
if (text.test(enclosingString(source, m.index))) {
found.push({ hue, index: m.index, fix: `text-${hue === 'moss' ? 'success' : hue}-fg` });
}
}
}
return found;
}
/** CSS rule blocks that put color: var(--fs-X) on a color-mix tint of X. */
export function findCss(source) {
const found = [];
for (const m of source.matchAll(/\{([^{}]*)\}/g)) {
for (const hue of Object.keys(HUES)) {
const tint = new RegExp(
`background(?:-color)?\\s*:\\s*color-mix\\(\\s*in srgb\\s*,\\s*var\\(--fs-${hue}\\)\\s*\\d+%\\s*,\\s*transparent\\s*\\)`
);
const text = new RegExp(`(?<![-\\w])color\\s*:\\s*var\\(--fs-${hue}\\)`);
if (tint.test(m[1]) && text.test(m[1])) {
found.push({ hue, index: m.index, fix: `var(--fs-${HUES[hue]})` });
}
}
}
return found;
}
// Hues that fail AA as text even on a plain surface, so the raw hue is never
// text at all: error measures 2.64:1 on slate and 3.23 on iron in dark mode,
// against 4.5; error-fg measures 5.30 at worst. A border or outline in the
// hue is a graphic with a 3:1 floor and stays allowed.
export const TEXT_NEVER_RAW = ['error'];
/** Raw text-X classes and color: var(--fs-X) for the hues above. */
export function findRawText(source) {
const found = [];
for (const hue of TEXT_NEVER_RAW) {
const patterns = [
[new RegExp(`(?<=^|[\\s'"\`:])text-${hue}(?=$|[\\s'"\`=])`, 'g'), `text-${hue}-fg`],
[new RegExp(`(?<![-\\w])color\\s*:\\s*var\\(--fs-${hue}\\)`, 'g'), `var(--fs-${HUES[hue]})`]
];
for (const [re, fix] of patterns) {
for (const m of source.matchAll(re)) found.push({ hue, index: m.index, fix });
}
}
return found;
}
function lineOf(source, index) {
return source.slice(0, index).split('\n').length;
}
function* walk(dir) {
for (const name of readdirSync(dir)) {
const path = join(dir, name);
if (statSync(path).isDirectory()) yield* walk(path);
else if (/\.(svelte|ts|js|css)$/.test(name) && !/\.test\.(ts|js)$/.test(name)) yield path;
}
}
/** Every violation under root, as "path:line hue → fix". */
export function scan(root, base = root) {
const out = [];
for (const path of walk(root)) {
const source = readFileSync(path, 'utf8');
for (const f of [...findTailwind(source), ...findCss(source), ...findRawText(source)]) {
out.push(`${relative(base, path)}:${lineOf(source, f.index)} ${f.hue} → ${f.fix}`);
}
}
return out;
}