DEV Community

Thedolceway
Thedolceway

Posted on

I audited 99 resume templates for contrast. Nine rendered the candidate's name invisible.

I run a resume builder with 99 templates. A user said the designs looked "basic", so I
wrote a script to measure them instead of arguing about taste. The script did not find a
taste problem. It found that nine templates rendered the candidate's name at a contrast
ratio of about 1.0 against its background.

Contrast 1.0 means the text and the background are the same colour. Two of them were
literally white on white. The name — the single most important string on the document —
was invisible.

Nobody had reported it, because nobody could see it.

Why nobody noticed

The templates are generated. A base design defines a palette, and variants override parts
of it. That is a completely normal pattern and it is where the bug lives.

The override was a shallow merge of a colors object. So a variant could set a pale
headerBackground while silently inheriting headerText from a dark base design. Both
values are individually reasonable. The combination is unreadable, and nothing in the type
system objects, because both are valid colour strings.

The failure only exists in the pair, and nothing was checking pairs.

Measuring instead of eyeballing

Contrast ratio is defined in WCAG and is fully mechanical. Relative luminance per channel:

const chan = (c) => {
  c = c / 255;
  return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
};

const luminance = ({ r, g, b }) =>
  0.2126 * chan(r) + 0.7152 * chan(g) + 0.0722 * chan(b);

const contrast = (a, b) => {
  const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
  return (hi + 0.05) / (lo + 0.05);
};
Enter fullscreen mode Exit fullscreen mode

The range is 1 to 21. AA wants 4.5 for body text, 3.0 for large text. Anything reporting
close to 1.0 is not "low contrast", it is the same colour, and it should be treated as a
crash rather than a style nit.

Rendering each template with sample data and walking the DOM took an afternoon. The result:
nine invisible names, and — the part I did not expect — 92 failing nodes in sidebars,
including 14 templates rendering certification names at exactly 1.00.

Sidebars were worse than main content because dark-sidebar designs are the ones most likely
to inherit a light-background text colour. The pattern that looks best is the pattern most
exposed to the bug.

Two things the audit taught me that I did not expect

The inherited claims were mostly wrong. I started from a previous audit's notes that
said 69 templates had five or more failing nodes and that one template rendered every
heading at 1.00. Measured: the worst had four, and that template's headings came in at
11.83, comfortably passing. The described root cause was a function that no longer existed
in the codebase — only a comment saying it used to.

If I had trusted the notes I would have "fixed" three things that were not broken and
missed the 92 that were. Re-measure before you refactor, especially when the notes sound
confident.

My type checker was checking nothing. While wiring the audit into CI I ran
tsc --noEmit and it passed instantly on a codebase I knew had errors. The root
tsconfig.json had "files": []. It was type checking an empty set, and had been for
a long time, exiting 0 the whole way. The real command was
tsc -p tsconfig.app.json --noEmit, which reported a 65-error backlog nobody knew about.

A green check on an empty input looks exactly like a green check on a clean codebase.

The fix that did not break the designs

The tempting fix is to force every text colour to something safe. That flattens every
design at once, and the designs were mostly fine.

Instead I resolved colours at render time and substituted only when a pair actually
fails
:

function readable(fg, bg, fallbackDark, fallbackLight) {
  if (contrast(parse(fg), parse(bg)) >= 4.5) return fg;      // untouched
  return luminance(parse(bg)) > 0.5 ? fallbackDark : fallbackLight;
}
Enter fullscreen mode Exit fullscreen mode

Every passing node stays byte-identical. Only the broken pairs change, and they change to
the correct end of the scale for their background. Final audit: 0 failing nodes across
99 templates
, with no design regressions, because nothing that worked was touched.

The audit script stayed in the repo. A rule that is not enforced is a rule that comes back.

What I would take to any generated-design system

  • Shallow-merging a palette is a correctness bug waiting to happen. Validate the resulting pairs, not the individual values.
  • Contrast is measurable, so measure it. "Looks fine to me" is not a test, and the people who cannot read it are not in the room.
  • Distrust audit notes, including your own. Re-measure. Confident prose about a function that no longer exists is a real thing that happens.
  • Verify your verifier. Feed it a known-bad input and confirm it fails. An empty files array will pass every check you write.

The templates this came from are at CVBooster — free to
browse, no account, and now with every one of them actually legible.

Top comments (0)