Files
minstrel/web/scripts/check-tint-contrast.js
T
bvandeusenandClaude Opus 5.5 fdee77eaec
release / web (push) Successful in 1m22s
release / govulncheck (push) Successful in 38s
release / go (push) Successful in 1m48s
release / integration (push) Successful in 5m1s
release / android (push) Successful in 5m41s
release / Build signed APK (releases and dev) (push) Successful in 5m11s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m17s
release / Verify release artifacts (tag releases only) (push) Skipped
fix(web): text on a tint of its own hue uses the house -fg tokens (#3150)
A hue painted as text on a color-mix tint of itself sits close to the
surface under it. On Minstrel's surfaces the raw accent on its 15% tint
measures 1.97:1 at worst (dark mode, hover surface), against AA's 4.5.

- tokens.json gains colors.fg: the five FabledSword -fg formulas (accent
  45%, success 45%, warning, error and info 50%), each mixed toward
  parchment so one declaration serves both modes. Success is Minstrel's
  moss. tokens-to-css emits them in :root.
- Tailwind exposes them as text-accent-fg, text-warning-fg, text-error-fg
  and text-info-fg.
- 23 sites swapped: 14 Tailwind class strings (PlayerBar and the admin
  count pills) and 9 CSS rules (StatusPill's four tones and five accent
  chips). Worst case after: accent-fg 5.03, error-fg 4.75, warning-fg
  4.92, success-fg 4.85.
- scripts/check-tint-contrast.js finds the pair in either spelling. Its
  test scans src in the web Vitest lane and fails on any new site, with
  fixture cases showing it can fail.

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

90 lines
3.3 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;
}
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)]) {
out.push(`${relative(base, path)}:${lineOf(source, f.index)} ${f.hue} → ${f.fix}`);
}
}
return out;
}