Module 4: Scales Color And Contrast

Project: Mercado's scales and contrast audit

Overview

The seven previous lessons taught you to understand scales and contrast separately: the spacing one (4px base), the typographic one (modular, base × ratio^n), the step-based color palette, the contrast ratio and its AA/AAA thresholds, the real algorithm that calculates it, and how to choose accessible pairs. This project puts you to defining them together and verifying them. You're going to build, in a single Node file, Mercado's three scales —spacing, typography, and the palette with its roles— and then audit the contrast of product-card's color pairs, marking which pass AA/AAA and fixing the one that fails. In modules 2 and 3 you manufactured the tokens and utilities; here you give those tokens the coherent values they come from —the scales— and the accessibility guarantee a system must have —measured contrast.

The deliverable has two parts, and both matter. Part 1 is the scales: spacing derived from the 4px base, typography derived from the 1.25 ratio, and color roles pointing to palette steps —the values behind module 2's tokens, now generated by rules instead of typed in. Part 2 is the audit: running contrastRatio over product-card's pairs, classifying them with passesWCAG, finding the one that doesn't reach AA, and proving the fix by moving up a step. Together they prove you master the module: not just what scales and contrast are, but how to define a real component's scales and guarantee its colors are readable.

Connection with the module. This project is the synthesis of the seven lessons. It derives spacing (L2) and typography (L3) from their rules, maps roles to palette steps (L4), and audits the pairs with the real algorithm (L6) against the thresholds (L5), fixing the one that fails via the ramp (L7). It's also the close of the guide's first half: the coherence scales and the accessibility contrast module 5's components and module 6's dark mode will take for granted. By the end, Mercado's token layer has values with an origin and proven contrast.

What you'll build

The deliverable is a Node file —scales.js— that, when run, prints five blocks:

  1. The spacing scale (4px base): a few steps derived with space(step).
  2. The typographic scale (16px base, 1.25 ratio): the sizes derived with fontSize(n).
  3. The color roles → palette step: color.primary, color.surface, color.text, color.muted, each pointing to a step (the initial choice, made partly "by eye").
  4. product-card's contrast audit: every foreground-over-background pair with its ratio and level (AA/AAA/FAIL).
  5. The fix for the failing pair: the secondary text from gray.400 to gray.500, measured before and after.

There are no React components or Tailwind classes to write here: it's pure Node modeling, like every "What to expect" in the module. The browser doesn't run inside an agent, so we show what would be written and execute the logic that validates it. The components that consume these scales arrive in module 5; here you manufacture the values and guarantee their contrast.

An analogy: the measurement blueprint and the readability proof before printing the catalog

A printer about to produce Mercado's catalog —hundreds of thousands of product cards— doesn't improvise. Before turning on the press it does two things. First it fixes its master measurement blueprint: the size of every margin, every text body, every gap, all derived from a base grid so the hundreds of thousands of copies come out identical and coherent —nobody measures "by eye" at the press, because one loose millimeter multiplies by hundreds of thousands. Second, it runs a readability proof: it prints a sample card and looks at it under different lights, with different eyes, to confirm the text reads —because once the press runs, unreadable text is an entire wasted print run.

Your project is those two things. Part 1 is the measurement blueprint: the scales every space and size comes from, so every product-card in the storefront is coherent. Part 2 is the readability proof: the contrast audit that confirms, with a number and not a glance, that the card's text reads for anyone. And like any good proof, it finds a problem before the run: the secondary light-gray text that "looked fine" but fails —and you fix it by moving up a step, not redoing the design. A printer who fixes their blueprint and runs their proof prints with confidence; one who improvises discovers the mistake on copy number one hundred thousand. You prove it by running code, before product-card repeats across all of Mercado.

Project specification

Your scales.js must satisfy this:

Part 1 — the three scales.

  • Spacing: define SPACE_BASE = 4 and space(step) = step × SPACE_BASE; print a few steps (e.g. 1, 2, 4, 6, 8, 12).
  • Typography: define TYPE_BASE = 16, TYPE_RATIO = 1.25, and fontSize(n) = base × ratio^n (rounded); print steps text-sm (−1) through text-2xl (3).
  • Color: define the palette with the steps you'll use (blue.400/600, gray.50/400/500/900, white) and map semantic roles to steps: color.primary → blue.600, color.surface → white, color.text → gray.900, color.muted → gray.400 (the initial choice). Print the roles with their hex.

Part 2 — the contrast audit.

  • Implement contrastRatio(fg, bg) with WCAG's real algorithm (hex→RGB, linearize, relative luminance, ratio) and passesWCAG(ratio, { large }) with the AA/AAA thresholds.
  • Audit at least these four product-card pairs: card.text/card.bg, price/card.bg, button.txt/button.bg, muted.text/card.bg. Print each one's ratio and level, marking the one that fails.
  • Show the fix for the failing pair: measure muted.text with gray.400 (fails) and with gray.500 (passes).

Constraints (the module's conventions):

  • Every identifier, token, step, hex, and level in English; only comments and text in English (or your locale's prose).
  • No dependencies: plain JavaScript, runs with node scales.js.
  • Literal, reproducible output.
  • Scales get derived from their rules (no typed-in lists); contrast uses the real algorithm (verifiable: #000000/#ffffff = 21:1).

Reference solution

Here's a complete solution that satisfies the specification. Study it after you've tried it on your own; the value of the project is in building it yourself, not in reading the answer:

// L8 project - Mercado's scales (spacing, typography, color) + contrast audit.

// ---------- 1) spacing scale: 4px base ----------
const SPACE_BASE = 4; // px
function space(step) { return step * SPACE_BASE; } // px

// ---------- 2) type scale: 16px base, 1.25 ratio ----------
const TYPE_BASE = 16, TYPE_RATIO = 1.25;
function fontSize(n) { return Math.round(TYPE_BASE * Math.pow(TYPE_RATIO, n) * 100) / 100; } // px
const typeSteps = { 'text-sm': -1, 'text-base': 0, 'text-lg': 1, 'text-xl': 2, 'text-2xl': 3 };

// ---------- 3) color palette: ramps by 50..900 steps ----------
const palette = {
  blue: { 400: '#60a5fa', 600: '#2563eb' },
  gray: { 50: '#f9fafb', 400: '#9ca3af', 500: '#6b7280', 900: '#111827' },
  white: '#ffffff',
};

// ---------- 4) WCAG contrast (real algorithm) ----------
function hexToRgb(hex) { const n = parseInt(hex.slice(1), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; }
function linearize(ch) { const c = ch / 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }
function relativeLuminance(hex) { const [r, g, b] = hexToRgb(hex).map(linearize); return 0.2126 * r + 0.7152 * g + 0.0722 * b; }
function contrastRatio(fg, bg) {
  const L1 = relativeLuminance(fg), L2 = relativeLuminance(bg);
  return (Math.max(L1, L2) + 0.05) / (Math.min(L1, L2) + 0.05);
}
function passesWCAG(ratio, { large } = {}) {
  const aa = large ? 3 : 4.5, aaa = large ? 4.5 : 7;
  if (ratio >= aaa) return 'AAA';
  if (ratio >= aa)  return 'AA';
  return 'FAIL';
}
const r2 = (x) => Math.round(x * 100) / 100;

// ===== output =====
console.log('=== 1) spacing scale (4px base) ===');
console.log([1, 2, 4, 6, 8, 12].map((s) => 'space-' + s + '=' + space(s) + 'px').join('  '));

console.log('\n=== 2) type scale (16px base, 1.25 ratio) ===');
for (const [name, n] of Object.entries(typeSteps)) console.log('  ' + name.padEnd(11) + fontSize(n) + 'px');

console.log('\n=== 3) color roles -> palette step (initial choice) ===');
const roles = {
  'color.primary': palette.blue[600],
  'color.surface': palette.white,
  'color.text':    palette.gray[900],
  'color.muted':   palette.gray[400], // chosen "by eye": looks subtle. The audit will say if it holds up.
};
for (const [role, hex] of Object.entries(roles)) console.log('  ' + role.padEnd(16) + hex);

console.log('\n=== 4) product-card contrast audit ===');
console.log('  ' + 'pair'.padEnd(24) + 'ratio'.padEnd(10) + 'level');
console.log('  ' + '-'.repeat(42));
const pairs = [
  { name: 'card.text / card.bg',   fg: palette.gray[900], bg: palette.white,     large: false },
  { name: 'price / card.bg',       fg: palette.blue[600], bg: palette.white,     large: false },
  { name: 'button.txt / button.bg',fg: palette.white,     bg: palette.blue[600], large: false },
  { name: 'muted.text / card.bg',  fg: palette.gray[400], bg: palette.white,     large: false },
];
for (const p of pairs) {
  const ratio = contrastRatio(p.fg, p.bg);
  const level = passesWCAG(ratio, { large: p.large });
  const mark = level === 'FAIL' ? '  <-- FAILS' : '';
  console.log('  ' + p.name.padEnd(24) + (r2(ratio) + ':1').padEnd(10) + level + mark);
}

console.log('\n=== 5) fixing the failing pair: gray.400 -> gray.500 ===');
const before = contrastRatio(palette.gray[400], palette.white);
const after  = contrastRatio(palette.gray[500], palette.white);
console.log('  muted.text gray.400 (#9ca3af): ' + r2(before) + ':1  ' + passesWCAG(before, {}));
console.log('  muted.text gray.500 (#6b7280): ' + r2(after)  + ':1  ' + passesWCAG(after, {}));

What to expect. Running node scales.js, the output is exactly this:

=== 1) spacing scale (4px base) ===
space-1=4px  space-2=8px  space-4=16px  space-6=24px  space-8=32px  space-12=48px

=== 2) type scale (16px base, 1.25 ratio) ===
  text-sm    12.8px
  text-base  16px
  text-lg    20px
  text-xl    25px
  text-2xl   31.25px

=== 3) color roles -> palette step (initial choice) ===
  color.primary   #2563eb
  color.surface   #ffffff
  color.text      #111827
  color.muted     #9ca3af

=== 4) product-card contrast audit ===
  pair                    ratio     level
  ------------------------------------------
  card.text / card.bg     17.74:1   AAA
  price / card.bg         5.17:1    AA
  button.txt / button.bg  5.17:1    AA
  muted.text / card.bg    2.54:1    FAIL  <-- FAILS

=== 5) fixing the failing pair: gray.400 -> gray.500 ===
  muted.text gray.400 (#9ca3af): 2.54:1  FAIL
  muted.text gray.500 (#6b7280): 4.83:1  AA

Read the output as the deliverable it is, in its two parts.

Part 1 (blocks 1–3) is your measurement blueprint. Spacing (space-4 = 16px) comes from the 4px base; typography (text-lg = 20px) comes from the 1.25 ratio —every size is 1.25 times the previous one: 20/16, 25/20, 31.25/25, all exactly 1.25— and the color roles point to palette steps (color.primary → #2563eb, which is blue.600). None of these values are typed in by hand: all of them are derived from a rule or point to a step. That's the module's point —a system's values have an origin, they aren't tastes. Notice color.muted = #9ca3af (gray.400): chosen "by eye" because it looks subtle. Part 2 will say whether that choice survives measurement.

Part 2 (blocks 4–5) is your readability proof, and it finds the problem before the run. Three of the four pairs pass: the product name (gray.900/white) gives 17.74:1 AAA, and the price and button (blue.600 with white, in either order because the ratio is symmetric) give 5.17:1 AA. But the fourth —the secondary muted.text in gray.400— gives 2.54:1 and FAILS AA. The gray that "looked fine" doesn't read. Block 5 fixes it the way the system allows: moving up a step, from gray.400 to gray.500 (#6b7280), which gives 4.83:1 and passes AA —without inventing a color, without touching the background, just moving along the ramp. That gray.500 becomes color.muted's real value for text.

Put the two parts together and you have Mercado's system's first half closed out: the scales that give coherence to every space, size, and color (part 1) and the contrast that guarantees the UI reads (part 2). It's the module's theory turned into a real component's blueprint and proof, executed.

Extensions (optional, to go further)

If you want to squeeze more out of the project, try these extensions —each one reinforces a lesson from the module:

  • Verify the algorithm (L6). Before the audit, add the golden test: contrastRatio('#000000', '#ffffff') must give exactly 21 and contrastRatio('#ffffff', '#ffffff') exactly 1. If they don't come out clean, your algorithm has drifted from the real one. It's the safety net for the whole contrast block.
  • Audit large text (L5). Add a large-text pair (a text-2xl, large: true) and show how a ratio that fails for normal text can pass for large. Confirm passesWCAG uses the correct threshold based on large.
  • Measure against the wrong background (L5/L7). Measure muted.text gray.500 against blue.600 instead of white and you'll see 1.07:1 —invisible. Demonstrate why the text's real background is what counts, not the body's.
  • Emit the scales as Tailwind config (L2/L3 + M3). Generate, from space and fontSize, the theme.extend.spacing and theme.extend.fontSize object Tailwind would read. You'll see the scale you built connecting to module 3's config.

None of these are required to complete the project; all of them are good practice for the rest of the guide.

Common mistakes

Typing in the scale values instead of deriving them. What happens: space-4 = 16px and text-lg = 20px get written as literals, without the formula. Why it happens: it's faster and "the result is the same". How to spot it: your code has a list of values instead of a step × base or base × ratio^n function. How to fix it: derive the scales from their rules. The literal value matches today, but you lose what makes a scale a scale: changing the base (or the ratio) recalculates everything at once, and adding a rung means evaluating the formula, not looking one up in a list. A typed-in scale is a disguised list of loose values —exactly what the module exists to replace.

Approving a color by how it looks and skipping the audit. What happens: color.muted = gray.400 gets defined because it looks elegant and part 2 doesn't get run. Why it happens: on the designer's monitor, 2.54:1 "reads". How to spot it: your project has the scales but not the audit, or the audit doesn't flag any failure when gray.400 should fail. How to fix it: half the deliverable is the audit —a system with no measured contrast isn't finished. gray.400 over white is the canonical failure (2.54), and it only shows up if you measure. Always run part 2; the number finds what the eye approves.

Making up the output instead of running it. What happens: the "What to expect" gets written by hand, calculating the ratios mentally. Why it happens: it feels like you "already know" what it'll give. How to spot it: your reported output doesn't match the real one character for character —a ratio with the wrong decimal, a misclassified level. How to fix it: actually run node scales.js and paste its literal output. The whole module stands on the honesty of "this is what the machine printed". A ratio miscalculated by hand is exactly the kind of error measured contrast exists to eliminate; don't reintroduce it into the verification —least of all in a number accessibility depends on.

Self-assessment rubric

Check off each point; if they're all checked, you've mastered the module:

  • Spacing derived. space(step) = step × 4 generates the series; the values aren't typed in.
  • Modular typography. fontSize(n) = 16 × 1.25^n generates the sizes; each one is 1.25× the previous.
  • Roles → steps. Color roles point to palette steps (color.primary → blue.600), not loose hex codes.
  • Real contrast. contrastRatio implements WCAG's algorithm (sRGB linearization, luminance, +0.05) and gives 21:1 for black/white.
  • Complete audit. product-card's four pairs get measured and classified; the muted pair (gray.400) comes out as FAIL.
  • Fix via the ramp. The failing pair gets fixed by moving up a step (gray.400 → gray.500), not by inventing a color; gray.500 passes AA.
  • Literal output. You actually ran node scales.js and the output matches what you're reporting —you didn't make up the output.

Summary and module closing

With this project you closed module 4 by doing, not just reading. You defined Mercado's three scales —spacing derived from the 4px base, typography derived from the 1.25 ratio, and the color palette with roles pointing to steps— and audited the contrast of product-card: three pairs pass (the name AAA with 17.74:1, the price and button AA with 5.17:1) and one fails (the secondary text gray.400 with 2.54:1), which you fixed by moving up to gray.500 (4.83:1, AA). With the printer who fixes their measurement blueprint and runs their readability proof before the print run, you saw why it's done that way: you derive values from rules so copies are coherent, and you measure contrast to guarantee they read —finding the problem before it multiplies across the whole storefront.

Take a step back and look at what you learned across the eight lessons. You know spacing comes from a small base and its multiples (L2), typography comes from a geometric modular scale (L3), and color is a palette of ramps in lightness steps (L4). You know contrast is a number with fixed thresholds —AA 4.5/3, AAA 7/4.5— that the system must guarantee (L5), that it gets calculated with a real four-step algorithm verifiable against 21:1 (L6), and that choosing accessible pairs means measuring the pair and, if it fails, moving along the ramp (L7). The values you only consumed in modules 2 and 3 now have an origin and a guarantee: every space, size, and color pair of Mercado's comes from a rule and passes (or gets fixed to pass) a measured threshold.

What you haven't done yet —on purpose— is apply these scales and colors to a configurable component. You defined the values and tested their contrast, but you didn't build a Button that receives size="lg" and chooses your scale's text-lg, nor a ProductCard with variants. That starts now. Module 5 — Components with Variants takes these scales and puts them inside components configured by props (the cva pattern): a Button whose size chooses a step from your spacing and typography scale, whose variant chooses a color pair that —you already know— must pass contrast. The scales you defined here start living inside the components that consume them.

Resources

  • WebAIM, "Contrast Checker" — webaim.org/resources/contrastchecker. Verify your audit's ratios (gray.900/white = 17.74, blue.600/white = 5.17, gray.400/white = 2.54); the reference tool. In English.
  • W3C, "Contrast (Minimum)" (WCAG 2.1, 1.4.3) — w3.org/WAI/WCAG21/Understanding/contrast-minimum.html. The AA threshold your audit applies; the normative reference. In English.
  • Tailwind CSS, "Theme" — tailwindcss.com/docs/theme. How the scales you defined (spacing, fontSize, colors) get declared in the config to generate utilities —the bridge back to module 3. In English.
  • Type Scale — type-scale.com. Verify your type scale: base 16, ratio 1.25, and compare the sizes with what your project printed. In English.