Files
minstrel/web/scripts/check-tint-contrast.js
T
bvandeusenandClaude Opus 5.5 b46c080d19
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m11s
release / go (push) Successful in 1m26s
release / integration (push) Successful in 4m18s
release / android (push) Successful in 4m44s
release / Build signed APK (releases and dev) (push) Successful in 4m53s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
fix(web): all accent text and icons use accent-fg (#5318)
The raw accent fails AA as text on every dark surface, not only on its
own tint: 3.04:1 on the page, 2.70 on iron, 2.21 on slate, against 4.5.
accent-fg (the house formula, 45% toward parchment) measures 5.62 at
worst across both modes. The operator chose the readable colour over the
signature teal for text, on 2026-10-08.

- 36 sites swap. They are 35 Tailwind uses: links, "Now playing", the
  ingest progress line, active shuffle/repeat, the liked heart, the app
  download icon and its hover. The last is the alphabet rail's pending
  spinner in CSS. Icons follow the text: as graphics they need only
  3:1, and the raw accent misses even that on iron.
- check-tint-contrast adds accent to TEXT_NEVER_RAW, so a new raw
  text-accent or color: var(--fs-accent) fails the web lane. Run against
  the files before the swap, it finds all 36. Borders, rings and fills
  keep the raw accent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 00:17:19 -04:00

114 lines
4.4 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. Dark mode, against 4.5:
// error 2.64 on slate, 3.23 on iron → error-fg 5.30 at worst
// accent 2.21 on slate, 3.04 on the page → accent-fg 5.62 at worst (#5318)
// Accent icons (the liked heart, active shuffle/repeat) follow too: as
// graphics they only need 3:1, and the raw accent misses even that on iron.
// A border or outline in the hue is a graphic and stays allowed.
export const TEXT_NEVER_RAW = ['error', 'accent'];
/** 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;
}