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:
- The spacing scale (4px base): a few steps derived with
space(step). - The typographic scale (16px base, 1.25 ratio): the sizes derived with
fontSize(n). - 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"). product-card's contrast audit: every foreground-over-background pair with its ratio and level (AA/AAA/FAIL).- The fix for the failing pair: the secondary text from
gray.400togray.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 = 4andspace(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, andfontSize(n) = base × ratio^n(rounded); print stepstext-sm(−1) throughtext-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) andpassesWCAG(ratio, { large })with the AA/AAA thresholds. - Audit at least these four
product-cardpairs: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.textwithgray.400(fails) and withgray.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 exactly21andcontrastRatio('#ffffff', '#ffffff')exactly1. 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. ConfirmpassesWCAGuses the correct threshold based onlarge. - Measure against the wrong background (L5/L7). Measure
muted.text gray.500againstblue.600instead of white and you'll see1.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
spaceandfontSize, thetheme.extend.spacingandtheme.extend.fontSizeobject 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 × 4generates the series; the values aren't typed in. - Modular typography.
fontSize(n) = 16 × 1.25^ngenerates 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.
contrastRatioimplements WCAG's algorithm (sRGB linearization, luminance,+0.05) and gives21:1for black/white. - Complete audit.
product-card's four pairs get measured and classified; themutedpair (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.500passes AA. - Literal output. You actually ran
node scales.jsand 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.