Working competence · 12 units · 4 weeks

Color Science for Design Systems

OKLCH, gamut mapping, and why the contrast ratio in your linter is a model — not a measurement.

0 / 12
units complete · each unlocks a 30° sector of your display's gamut

Start here

The capability. At the end of this course you can author a design-token palette in OKLCH, prove which of its colors your display cannot actually show, and write a one-page contrast policy that says what your numbers mean and where they stop being true — and defend all three to an engineer who disagrees with you.

Not "understand color." Three artifacts, checkable by someone else.

Who this assumes you are

Comfortable in CSS and JavaScript; has shipped design tokens and at least one dark mode; can read a matrix multiply without flinching but has never derived a color transform. Has seen oklch() in a stylesheet and possibly pasted one. Knows 4.5:1 as a rule that a linter enforces, and has had the experience of a dark-mode pair that passed the linter and still looked wrong. No prior exposure to CIE colorimetry, tristimulus values, or chromaticity.

If that is not you — if you have never shipped tokens — the payoff in Week 1 will still land, but Week 4 will have nothing to attach to.

What this course does not do

Time

The twelve units sum to about 9 hours 10 minutes of stated work. Plan for ~23 hours. That is the stated time multiplied by 2.5, which is roughly how much longer self-paced learners actually take than a course estimates Inference. The multiplier is not padding for slow reading — it is where the stuck parts go, and this course has three of them, marked.

Per-unit estimates are printed on every unit. A unit is meant to be openable and closeable in one sitting. Close one and stop; that is the design, not a failure of stamina.

How claims are marked

Sourced fact S1
Traceable to a numbered source at the bottom that I opened and read.
Inference
My reasoning, my computation, or my application. Appears in no source. Check it.
Vendor framing
Accurate, but authored by someone with a stake in the thing being described.
Contested
Practitioners and standards bodies genuinely disagree, right now.
Single study
Real, unreplicated, or a preprint. Directionally useful, not settled.

Pay attention to Vendor framing in particular. In color on the web, the clearest writing on almost every topic is produced by someone shipping a tool, a color picker, a contrast checker, or a standard. That does not make it wrong. It makes it shaped. Learning to read this field means learning to see the shape — which is why the markers are here rather than in a methodology appendix.

Three places you are supposed to get stuck

Unit 3, when Oklab lightness and HSL lightness disagree and you cannot yet say which one is "right." Unit 6, when you realize maximum chroma is not a number you can look up. Unit 9, when a standard you have complied with for years turns out to rest on a citation that may not say what it is cited for.

Each of those is the crossing, not an obstacle before it. If a unit feels like it is taking too long and you cannot summarize what you just read, that is the expected condition and it resolves in the next unit, not this one.

Week 1

Ship it, then find out why it's wrong

4 units · ~2 hr 30 min stated
Build~45 min

Unit 1 · A whole palette, badly

This unit produces

Three files, all crude, all complete: tokens.css with nine OKLCH colors in light and dark, a two-line gamut.md guessing which ones your display can't show, and a one-sentence contrast-policy.md. You will be wrong about most of it. That is the point — these are the three artifacts the whole course refines, and you need something to be wrong about before the theory has anywhere to land.

No theory first. Build the thing, then we take it apart.

Step 1 — write the ramp

Pick one brand hue. Something you actually use. Find its hue angle by pasting the hex into any OKLCH converter, or start from these: red ≈ 25°, orange ≈ 60°, yellow ≈ 95°, green ≈ 145°, teal ≈ 195°, blue ≈ 255°, purple ≈ 300°, magenta ≈ 340° Inference — these are rounded from my own conversions, good enough to start and wrong enough to fix later.

Now write nine steps at even lightness intervals, holding chroma and hue fixed:

:root {
  --brand-50:  oklch(0.97 0.16 255);
  --brand-100: oklch(0.92 0.16 255);
  --brand-200: oklch(0.85 0.16 255);
  --brand-300: oklch(0.77 0.16 255);
  --brand-400: oklch(0.68 0.16 255);
  --brand-500: oklch(0.58 0.16 255);
  --brand-600: oklch(0.48 0.16 255);
  --brand-700: oklch(0.38 0.16 255);
  --brand-800: oklch(0.28 0.16 255);
}

Put it in a real page — a scratch file, a Storybook story, whatever is fastest. Render the nine swatches as blocks. Look at them on the device you are reading this on.

Step 2 — the dark half

Make a dark variant by the most obvious method: reverse the ramp. --surface gets the darkest step in dark mode and the lightest in light mode, and so on. Do not think hard about it. Write it down.

Step 3 — guess, in writing

Create gamut.md. Two lines:

Steps I think my display cannot actually show: ___
Why I think that: ___

Step 4 — the policy

Create contrast-policy.md. One sentence, whatever you currently believe. Most people write something close to "all text pairs must hit 4.5:1 per WCAG AA." Write yours honestly, including if it is "whatever the linter says."

Self-explanation · gated

Look at your nine swatches. Does the ramp look evenly spaced in lightness to you — same-size step between every adjacent pair? Describe what you actually see, in your own words, before reading anyone's explanation. If it looks uneven, say where.

Before you commit: how confident are you that your description would match what a colorimeter says?
Recorded — no right answer yet

Most people report one of two things: the ramp looks close to even, or the light end looks compressed and washed out while the dark end looks fine. Both reports are common and both are informative.

Here is what you can't yet tell, and won't until Unit 4: whether the unevenness you see is in the ramp or in your display's inability to render the ramp you wrote. Those have different fixes. Hold your description; you will come back and score it.

Do not fix it yet

You will want to nudge the numbers until it looks right. Don't. A ramp you tuned by eye teaches nothing, because you cannot later tell which of your nudges corrected a real defect and which one corrected a defect that was never there. Ship it ugly and keep the receipts.

Read~35 min

Unit 2 · "Lightness" is four different numbers

This unit produces

A written distinction, in your own words, between four quantities that all get called lightness or brightness — and a statement of which one oklch()'s first argument is. Three sentences in a notes file. It is the supportive information Unit 3 computes.

The word "lightness" is doing four jobs.

1. HSL's L — a geometric artifact

HSL's lightness is (max(R,G,B) + min(R,G,B)) / 2 on the gamma-encoded values. That is a definition of convenience: it makes the color space a tidy double cone that is easy to draw a picker for. It is not a claim about perception, and it was never intended as one. Every fully saturated hue — pure blue, pure yellow, pure red — has HSL lightness of exactly 50%. Inference (this follows directly from the formula; verify it in Unit 3 rather than taking it from me).

2. Relative luminance, Y — physics, weighted by the eye

Linearize each sRGB channel to undo the transfer function, then take 0.2126R + 0.7152G + 0.0722B Sourced fact S5. The weights reflect that the eye is far more sensitive to green light than to blue at equal power. Y is a real physical quantity — how much light is coming off the pixel, weighted by human spectral sensitivity. It is linear: double Y and you have doubled the light.

Crucially: doubling the light does not double the apparent lightness. Y is what a light meter measures, not what you see.

3. CIE L* — the 1976 model of perceived lightness

CIELAB's L* applies a cube-root-ish compression to Y and scales to 0–100. The compression is the whole point: perceived lightness grows roughly as the cube root of luminance, so a color at half the luminance of white looks much more than half as light. L* = 50 is the mid-grey your eye picks, and it sits at about 18% of white's luminance, not 50%.

4. Oklab's L — a 2020 refit of the same idea

Oklab keeps CIELAB's structure — cone-response matrix, cube root, opponent-axis matrix — and re-fits the numbers. Ottosson designed it because CIELAB predicts hue badly, most visibly in blues, where holding L* and hue constant and varying chroma visibly shifts the color toward purple Sourced fact S1. Oklab's L runs 0–1 rather than 0–100. oklch(0.58 0.16 255) — the first number is this one.

Where Oklab's lightness actually comes from

This is the part most write-ups omit, and it changes how much weight the number can carry. Ottosson fit Oklab against three datasets: a lightness set and a chroma set that he generated using CAM16, plus the Ebner–Fairchild experimental hue data that was used to derive IPT Sourced fact S1. He states the consequence himself: because the lightness and chroma data were generated by a model rather than measured from people, that data cannot establish which model best matches human perception Sourced fact S1.

So Oklab's hue uniformity rests on human data. Its lightness and chroma rest on agreement with CAM16, a different model. A 2026 preprint puts a number on the consequence: measured against the COMBVD color-difference dataset, Oklab scores substantially worse than CIEDE2000 at predicting how different two colors look Single study S12 — which is unsurprising, since that was never the objective it was fit for, but is worth knowing before you use ΔEOK as if it were a perceptual difference metric. That is a defensible engineering choice and Ottosson is unusually direct about it. It is also not what "perceptually uniform" sounds like it means.

One more thing worth knowing before you meet it in the wild: the γ exponent in Ottosson's fit came out at 0.323 and he forced it to exactly 1/3, additionally constraining the fit so that blue colors would not fold inward and make the sRGB gamut non-convex. He reports the error was not noticeably affected Sourced fact S1. A convenient-looking constant that turns out to be a fitted parameter, rounded for a geometric reason, is a good example of what these spaces are made of.

Check · gated

A designer says: "I set both swatches to 50% lightness, so they're equally light." They used HSL. Which statement is the most precise correction?

Confidence before reveal:

The distinction that matters: the difference between HSL's L and Oklab's L is not one of accuracy. It is a difference of kind. HSL's L is not a bad estimate of perceived lightness; it is not an estimate of perceived lightness. Oklab's L is a fitted model of it, with the documented caveats above.

Option 1 is the tempting wrong answer, and it is wrong for a second reason too: Oklab L = 0.5 swatches at different hues are also not guaranteed to look equally light to you, only to the average of the fit. Option 3 treats a category error as a precision error, which is the specific mistake this unit exists to prevent.

Derive~40 min

Unit 3 · Five colors with the same lightness and nothing in common

This unit produces

A completed table, computed by you, of four lightness measures across five colors that HSL calls identical. Pen and paper or a REPL — either is fine, but the numbers have to come out of your hands, not out of a converter website. The table is the evidence that the Unit 2 distinction is real and not a semantic quibble.

Here are five sRGB colors. Every one of them has HSL lightness of essentially 50%.

#0000FF
#808080
#FFFF00
#FF0000
#00FF00

Look at them. Then compute.

The two formulas you need

Linearize a channel (undo the sRGB transfer function), for channel value c in 0–1 Sourced fact S5:

lin(c) = c/12.92 if c ≤ 0.04045 lin(c) = ((c + 0.055)/1.055)^2.4 otherwise

Relative luminance Sourced fact S5:

Y = 0.2126·lin(R) + 0.7152·lin(G) + 0.0722·lin(B)

CIE L* from Y Sourced fact S2:

L* = 116·Y^(1/3) − 16 for Y > 216/24389 L* = (24389/27)·Y otherwise

Worked example — #0000FF, all the way through

R = 0, G = 0, B = 255 → 1.0.

lin(0) = 0/12.92 = 0 lin(1.0) = ((1.0 + 0.055)/1.055)^2.4 = 1^2.4 = 1 Y = 0.2126·0 + 0.7152·0 + 0.0722·1 = 0.0722 Y > 0.008856, so: L* = 116·(0.0722)^(1/3) − 16 = 116·0.41637 − 16 = 48.30 − 16 = 32.3

So pure blue carries 7.2% of white's luminance, and sits at L* = 32 on a 0–100 perceptual scale. HSL called it 50.

Oklab L for the same color is 0.452 Inference — computed by me with Ottosson's published linear-sRGB matrices, which I verified against his published XYZ→Oklab test table before using them Sourced fact S1. The Oklab arithmetic is a matrix multiply and three cube roots; it is not illuminating to do by hand, so I am handing you those values and asking you to compute the other three columns yourself.

Completion problem — #FFFF00, fill the gaps

R = 1.0, G = 1.0, B = 0 lin(1.0) = ____ lin(0) = ____ Y = 0.2126·____ + 0.7152·____ + 0.0722·____ = ____ L* = 116·(____)^(1/3) − 16 = ____
Numeric · gated

What is Y for #FFFF00? Give four decimal places.

Confidence before reveal:

Y = 0.9278. Both linearized channels are 1.0, so Y = 0.2126 + 0.7152 = 0.9278. L* = 116·(0.9278)^(1/3) − 16 = 116·0.97534 − 16 = 97.1.

Pure yellow carries 92.8% of white's luminance. Pure blue carries 7.2%. HSL assigns both a lightness of 50%. The ratio between them is 12.9× in luminance and about 3× in L*.

Independent — the rest of the table

Compute Y and L* for #808080, #FF0000, and #00FF00 yourself, with no scaffold. Then check against this. The Oklab column is mine Inference; the Y and L* columns you should be able to reproduce to the digit.

ColorHSL LYCIE L*Oklab L
#0000FF blue50.0%0.072232.30.452
#808080 grey50.2%0.215953.60.600
#FF0000 red50.0%0.212653.20.628
#00FF00 green50.0%0.715287.70.866
#FFFF00 yellow50.0%0.927897.10.968

The stuck part

Here is where people stall, and it is worth naming: you now have four numbers per color and no principle for choosing between them. It feels like the course owes you a ranking — which column is right?

There isn't one, and reaching for one is the thing to resist. Y is right if you are asking how much light leaves the pixel. L* and Oklab L are models of a perceptual question, and they disagree with each other by a few points, and both of them disagree with your eye by more than that. HSL's L is right for nothing except drawing a picker.

The crossing isn't picking a winner. It's the moment "how light is this color" stops feeling like a question with an answer and starts feeling like a question that needs a "for what?" attached. If you are still waiting for the ranking, keep going — Unit 4 forces the "for what."

Diagnose~30 min

Unit 4 · Measure the ramp you shipped

This unit produces

A measured verdict on your Unit 1 ramp: for each of the nine steps, the Oklab L you asked for and the Oklab L your browser actually produced. Plus a scored comparison against the description you wrote in Unit 1.

Open your Unit 1 page. Paste this in the console. It reads back what the browser resolved each token to, converts to Oklab, and prints requested vs. actual.

function lin(c){return c<=0.04045 ? c/12.92 : Math.pow((c+0.055)/1.055,2.4)}
function oklabL(r,g,b){
  const l=0.4122214708*r+0.5363325363*g+0.0514459929*b;
  const m=0.2119034982*r+0.6806995451*g+0.1073969566*b;
  const s=0.0883024619*r+0.2817188376*g+0.6299787005*b;
  return 0.2104542553*Math.cbrt(l)+0.7936177850*Math.cbrt(m)-0.0040720468*Math.cbrt(s);
}
const cs=getComputedStyle(document.documentElement);
for(let i of ['50','100','200','300','400','500','600','700','800']){
  const v=cs.getPropertyValue('--brand-'+i).trim();
  const p=document.createElement('span');
  p.style.color=v; document.body.appendChild(p);
  const m=getComputedStyle(p).color.match(/[\d.]+/g).slice(0,3).map(Number);
  p.remove();
  console.log(i, v, '→ actual Oklab L =',
    oklabL(...m.map(x=>lin(x/255))).toFixed(3));
}

Note what this script is doing and what it is not. getComputedStyle().color gives you the value the browser resolved — which, for an oklch() that falls outside the output gamut, is already gamut-mapped. You are not reading your request back. You are reading what survived.

Diagnose · gated

Suppose your ramp's steps 50 through 300 all came back with actual Oklab L within 0.005 of what you asked for, but steps 400–600 came back lighter than requested by 0.02–0.04. What is the single most likely explanation?

Confidence before reveal:

Maximum available chroma is highest in the middle of the lightness range and falls toward both ends. A fixed chroma of 0.16 that is comfortably inside the gamut at L = 0.92 can be well outside it at L = 0.58, depending on the hue. When it is, the browser must do something, and what it does is the subject of Week 2.

Option 4 is plausible-sounding and wrong at this magnitude: 8-bit rounding moves Oklab L by well under 0.005, not 0.04. Option 2 is the reflex to blame the model — worth noticing in yourself, because it is the reflex that stops investigation.

You have just crossed the first threshold. "Lightness" is now a question that needs a qualifier, and you have a measurement showing that the number you write is not necessarily the number you get.

Self-explanation · scores Unit 1

Go back and read what you wrote in Unit 1 about whether the ramp looked evenly spaced. Compare it to the measurements you just took. Where were you right, where were you wrong, and — the part that matters — what would have made you notice sooner?

How well do you think your Unit 1 description held up?
Recorded

The most common honest answer to "what would have made me notice sooner" is rendering the ramp next to a neutral grey ramp at the same requested lightnesses. Chroma hides lightness error; a grey reference removes the hiding place. If you wrote something else that is concrete and testable, yours is probably better than mine — it is fitted to how you actually work.

Week 2

Your display is a triangle

4 units · ~2 hr 55 min stated
Read~40 min

Unit 5 · The horseshoe and the triangle

This unit produces

An annotated version of the diagram at the top of this page — sketch it, screenshot it, or redraw it — with four things labelled in your own words: the curved boundary, the straight boundary, the triangle vertices, and one region you can name that your display cannot reach. This is the supportive information for Units 6 and 7.

Scroll up. That shape has been watching you since Unit 1.

Where it comes from

In the 1920s, researchers measured how much of three reference lights people needed to mix to match each pure wavelength. The CIE standardised the averaged results in 1931 as three color matching functions, x̄, ȳ, z̄. Integrate a light's spectrum against them and you get three numbers — X, Y, Z — that fully determine what color a standard observer sees Sourced fact S9. Y is the same relative luminance you computed in Unit 3; that is not a coincidence, ȳ is the luminous efficiency curve.

Now throw the brightness away:

x = X / (X + Y + Z) y = Y / (X + Y + Z)

What is left is chromaticity — hue and colorfulness, with lightness divided out. Two dimensions, which is why it fits on a page. Plot every pure wavelength from about 410 nm to 645 nm and you get the curve: the spectral locus. Close it with a straight line and you get the line of purples, which are the colors with no single wavelength — mixtures of red and violet only. Inside that outline is every chromaticity a human can see.

What the diagram at the top is made of

Full disclosure, because the meter is teaching you something and you should be able to audit it. The locus is not tabulated CIE data. It is computed live from Wyman, Sloan and Shirley's multi-lobe Gaussian fit to the 1931 2° observer Sourced fact S9, which the authors report has mean squared error below the within-subject variance of the original measurements. I checked it against known locus points before using it: accurate to about ±0.005 in xy through the middle of the range, degrading to roughly ±0.02 at the extreme tips, where all three color matching functions approach zero and the ratio amplifies small errors Inference. That is why the meter draws 410–645 nm rather than 380–700 nm. At the size it renders, the difference is invisible; I am telling you because a progress bar that quietly fabricates its own data would be a poor advertisement for the discipline this course is trying to teach.

Why a display is a triangle

A display has three primaries. Additive mixing is linear in XYZ, so any color it can produce is a non-negative combination of those three — which, once you divide out luminance, means the convex hull of the three primary chromaticities. Three points, convex hull, triangle. There is no clever engineering that escapes this; it is geometry.

sRGB's primaries, from BT.709 Sourced fact S10:

GamutR (x, y)G (x, y)B (x, y)xy area
sRGB0.640, 0.3300.300, 0.6000.150, 0.060~34%
Display P30.680, 0.3200.265, 0.6900.150, 0.060~46%
Rec. 20200.708, 0.2920.170, 0.7970.131, 0.046~65%

The area figures are mine, computed by shoelace against the same fitted locus Inference.

Do not repeat the area percentages

I just gave you three numbers that you will see quoted everywhere, and you should treat all of them — including mine — as close to meaningless. The xy diagram is not perceptually uniform. It devotes an enormous share of its area to greens, where the eye discriminates poorly, and squeezes blues and violets into a corner. "sRGB covers 34% of visible color" is therefore a statement about the area of a particular 1931 projection, not about how much color you are missing. Inference

This is a live example of the thing to watch for: a number that is arithmetically correct, easy to compute, widely repeated, and does not support the claim it is used for. The meter on this page displays it anyway, with this warning attached, because the honest version of a teaching visual includes what it is bad at.

Check · gated

A vendor claims a new display technology reaches "120% of sRGB." What is the most useful first question?

Confidence before reveal:

"120% of sRGB" is a ratio of two areas, and both the choice of diagram (xy vs. u'v' vs. a perceptual space) and the direction of the excess are doing the work. A panel can score above 100% while covering less of sRGB than sRGB does, if its triangle is rotated — it gains area somewhere nobody's content lives and loses it where the content is. Vendor framing is the marker for this whole genre of claim, and the correct response is always "measured how."

Bit depth (option 3) is a real and separate concern — it governs banding within a gamut, not the gamut's extent.

Derive~45 min

Unit 6 · Maximum chroma is not a number you can look up

This unit produces

A working maxChroma(L, H) function, written by you from a skeleton, and a printed table of its output across six hues at three lightnesses. You will use this function in Unit 7 and again in Unit 12.

In Unit 1 you wrote nine colors that all shared 0.16 chroma. That was a guess, and it had to be — nothing in the oklch() syntax tells you what chroma is available.

Here is why it can't. The sRGB gamut is a cube in linear RGB. Oklch is a polar coordinate system in a nonlinearly-warped space. When you push that cube through the warp, you get a lumpy solid, and the maximum chroma at a given lightness and hue is the distance from the neutral axis to that solid's surface in that direction. There is no closed form. You find it numerically.

The test: is this color in gamut?

Convert Oklch → Oklab → linear sRGB and check whether all three components sit in [0, 1]. Ottosson's inverse matrices Sourced fact S1:

function oklchToLinearSRGB(L, C, H){
  const a = C * Math.cos(H * Math.PI/180);
  const b = C * Math.sin(H * Math.PI/180);
  const l_ = L + 0.3963377774*a + 0.2158037573*b;
  const m_ = L - 0.1055613458*a - 0.0638541728*b;
  const s_ = L - 0.0894841775*a - 1.2914855480*b;
  const l = l_**3, m = m_**3, s = s_**3;
  return [
     4.0767416621*l - 3.3077115913*m + 0.2309699292*s,
    -1.2684380046*l + 2.6097574011*m - 0.3413193965*s,
    -0.0041960863*l - 0.7034186147*m + 1.7076147010*s
  ];
}
const inGamut = rgb => rgb.every(c => c >= -1e-6 && c <= 1 + 1e-6);

Completion problem — finish the bisection

The gamut is star-shaped from the neutral axis at fixed L and H: if chroma c is in gamut, everything below it is too. That monotonicity is what licenses a binary search. Fill in the four blanks.

function maxChroma(L, H){
  let lo = 0, hi = 0.5;            // 0.5 exceeds any real sRGB chroma
  for (let i = 0; i < 40; i++){
    const mid = ________;
    if (inGamut(oklchToLinearSRGB(L, ____, H)))  ____ = mid;
    else                                          ____ = mid;
  }
  return lo;                        // lo is always in gamut
}
Numeric · gated · run your own code

Run your function. What is the maximum sRGB chroma at L = 0.7, H = 330°? Three decimal places.

Confidence before reveal:

0.314. If you got something within ±0.002 your implementation is right; the remaining difference is iteration count. If you got roughly half that, check that you are passing mid as chroma rather than hi.

Here is the whole table Inference — computed by me with the same method, which you can now verify rather than trust:

Oklab LH 30°H 90°H 150°H 210°H 270°H 330°
0.500.2010.1020.1380.0860.2810.228
0.700.1920.1430.1930.1210.1560.314
0.850.0820.1740.2340.1370.0740.133

Read that table slowly, because it contains the second threshold:

  • At a single lightness, available chroma varies by more than 3× across hue. At L = 0.50 it runs from 0.086 to 0.281.
  • The most chromatic hue changes with lightness. At L = 0.50 it is 270° (blue); at L = 0.85 it is 150° (green). Blue collapses from 0.281 to 0.074 over that range; green nearly doubles.
  • Therefore a fixed-chroma ramp is impossible in principle, not merely difficult. Any constant you pick is either clipped somewhere or leaving chroma unused everywhere else.

The stuck part

The natural next thought is: fine, so make chroma a function of lightness. Do that — write C = k · maxChroma(L, H) for some k and generate a ramp. It will be better, and it will also be visibly wrong in a new way, because holding a constant fraction of maximum makes the ramp's colorfulness track the gamut boundary's lumps rather than any intention of yours.

There is no formula that resolves this. Every real design system has picked a compromise and written it down. Unit 12 is where you pick yours; sitting in the discomfort until then is deliberate.

Build~60 min

Unit 7 · Three ways to fit a color that doesn't fit

This unit produces

A script that takes an out-of-gamut Oklch color and returns three answers — clipped, chroma-reduced, and CSS-spec-mapped — plus your written verdict on which your design system should use and what it costs. That verdict becomes a paragraph in the Unit 12 rationale.

You ask for oklch(0.55 0.32 264). sRGB cannot produce it. Something has to give. There are exactly three things that can give: chroma, lightness, or hue. Every gamut mapping strategy is a policy about which ones you are willing to spend.

Strategy 1 — clip

Convert to linear sRGB, clamp each channel into [0, 1], done. Fast, and it is what naïve code does by accident. It preserves nothing in particular: clamping channels independently moves you to a point on the gamut surface chosen by the arithmetic, not by any perceptual criterion. Lightness drifts. Hue can drift.

const clip = rgb => rgb.map(c => Math.min(1, Math.max(0, c)));

Strategy 2 — reduce chroma, hold L and H exactly

Binary search chroma down until you are inside — you wrote this in Unit 6. Lightness and hue are preserved to the digit. The cost is that it can be aggressive: it walks all the way in to the last strictly-in-gamut point, which for colors just barely outside can look noticeably duller than necessary.

const reduced = maxChroma(L, H);   // then rebuild at that chroma

Strategy 3 — what CSS actually does

The CSS Color 4 gamut mapping algorithm splits the difference. It binary-searches chroma in Oklch, but at each step it also clips the current candidate and measures the ΔEOK between the candidate and its clipped version. If that difference is under one just-noticeable difference — the spec uses 0.02 — it returns the clipped color instead of continuing to reduce. Abridged from the specification's pseudocode Sourced fact S3:

const JND = 0.02, EPS = 0.0001;

function gamutMap(L, C, H){
  if (L >= 1) return [1,1,1];
  if (L <= 0) return [0,0,0];
  let cur = oklchToLinearSRGB(L, C, H);
  if (inGamut(cur)) return cur;

  let lo = 0, hi = C, loInGamut = true;
  if (deltaEOK(clip(cur), cur) < JND) return clip(cur);

  while (hi - lo > EPS){
    const c = (lo + hi) / 2;
    cur = oklchToLinearSRGB(L, c, H);
    if (loInGamut && inGamut(cur)) { lo = c; continue; }
    const cl = clip(cur), E = deltaEOK(cl, cur);
    if (E < JND){
      if (JND - E < EPS) return cl;
      loInGamut = false; lo = c;
    } else hi = c;
  }
  return oklchToLinearSRGB(L, lo, H);
}
// deltaEOK is plain Euclidean distance in Oklab — that is the point of Oklab
const deltaEOK = (p, q) => { const a = toOklab(p), b = toOklab(q);
  return Math.hypot(a[0]-b[0], a[1]-b[1], a[2]-b[2]); };

Run all three

Implement the three and produce this table for yourself. Mine Inference, for checking — and the results are more interesting than the descriptions above suggest:

RequestedClipChroma-reduceCSS GMA
oklch(0.55 0.32 264)#0044ff#205cff#1656ff
→ resulting Oklab L0.5060.5500.537
→ ΔEOK from request0.06140.07160.0646
oklch(0.70 0.28 150)#00c91a#00be58#00c248
→ resulting Oklab L0.7240.7000.709
→ ΔEOK from request0.05530.08720.0713
oklch(0.80 0.22 90)#f4b300#e6b700#ebb600
→ resulting Oklab L0.8050.8000.802
→ ΔEOK from request0.06070.05650.0568
Read the data · gated

In two of the three rows, clipping has a lower ΔEOK than chroma-reduction — the "naïve" method is closer to the requested color than the "principled" one. What does that tell you about why the CSS algorithm is built the way it is?

Confidence before reveal:

Look at row 1 vs row 3. For the blue, clipping is closer overall but drops lightness by 0.044 — a visible shift in a ramp where lightness is the ordering principle. For the yellow, clipping and reducing are nearly identical in both respects, so there is nothing to trade.

The JND test is exactly a switch between regimes: if clipping's error is small enough to be invisible, take it; otherwise pay for lightness. Option 3 is a common misreading — the binary search does not converge to the chroma-reduced answer, it exits early with the clipped one, which is a different color.

This is the second threshold. Gamut mapping is not a correctness procedure with a right answer. It is a choice about what to spend, and the spec made one so that browsers agree — not because it is optimal.

Decision · gated · feeds Unit 12

For your design system: which strategy, and what does it cost you? The answer that will serve you best is not the "best" one — it's the one whose failure mode you can live with and explain to a colleague. Write two or three sentences.

Confidence that you'd defend this in a design review:
Recorded — bring this to Unit 12

One position worth considering, offered as a position and not a recommendation Inference: for token ramps specifically, exact lightness preservation is worth more than minimal ΔE, because the ramp's whole contract with downstream consumers is "step 600 is darker than step 500." A mapping that silently reorders two adjacent steps breaks that contract in a way no single-color error metric can see. That argues for chroma-reduction on ramps even where CSS's default would clip — which means doing the mapping at build time and shipping hex, not shipping oklch() and letting the browser decide.

The counter-argument is equally real: shipping oklch() means P3 displays get the wider color for free, and build-time mapping throws that away. If you wrote something closer to that, you are not wrong; you have a different priority.

Retrieve~30 min

Unit 8 · Which mechanism is this?

This unit produces

Five diagnoses. No new material — this unit exists because you have now been handed two mechanisms that produce similar-looking symptoms, and the skill that matters is telling them apart under time pressure. Answering these correctly is worth more than re-reading Units 2–7.

Everything below can be explained by one of these, and you have to pick which:

  • A · Lightness model mismatch — something is using a lightness measure that doesn't predict perception (Units 2–4).
  • B · Gamut boundary — the requested color doesn't exist on this display and got reconciled (Units 5–7).
  • C · Both, interacting — the reconciliation in B moved a quantity that A depends on.
  • D · Neither — the cause is somewhere else entirely.
Interleaved · gated

A designer's HSL-based ramp looks fine in blue but the yellow ramp's mid-steps look blown out and the dark steps look muddy. All ramps use the same HSL lightness values.

Confidence:

A. The tell is that the symptom is hue-dependent while the input is hue-independent. Yellow at HSL L = 50% carries Y = 0.9278; blue at HSL L = 50% carries Y = 0.0722. Same input, 12.9× different luminance. Nothing here requires the gamut — these colors are all inside sRGB by construction, since HSL is defined on sRGB.

That last sentence is the shortcut: an HSL color is never out of gamut. If the symptom is fully explained by HSL inputs, B is ruled out for free.

Interleaved · gated

A ramp authored in OKLCH at constant chroma renders correctly on a colleague's MacBook but the two mid-steps look nearly identical on your older external monitor.

Confidence:

B. The tell is that it is device-dependent. The MacBook is P3; the external monitor is sRGB. Both steps requested chroma beyond the sRGB boundary at their lightnesses, got mapped to nearby in-gamut colors, and the mapping compressed them toward each other.

Distinguishing B from C here: the question says the steps look identical, not reordered. Chroma collapsed; the lightness ordering survived. If step 500 had appeared lighter than step 400, that would be C.

Interleaved · gated

You author oklch(0.62 0.30 264) and oklch(0.58 0.30 264) as adjacent ramp steps. On an sRGB display the second one renders lighter than the first.

Confidence:

C, and this is the one that bites design systems. Both colors are outside sRGB. If the mapper clips rather than reduces chroma, lightness is not preserved — Unit 7's table shows clipping moving Oklab L by up to 0.044. Two steps only 0.04 apart in requested lightness can therefore swap order after mapping.

The ordering guarantee your ramp promises is a property of the requested colors, not the rendered ones. This is the concrete reason the Unit 7 verdict matters.

Interleaved · delayed from Week 1 · gated

Someone reports that #767676 text on white "passes contrast" but the same grey on a #121212 background "fails," and concludes their dark theme needs lighter text than their light theme needs darker text.

Confidence:

D. Both colors are neutral greys — no chroma, so no gamut issue — and both are being evaluated with the same measure, so no model mismatch between them. The asymmetry is coming from the contrast formula itself, and specifically from a constant in it that you have not met yet. That is Week 3.

The reason this item is here, in the middle of a gamut week: the habit worth building is checking whether a symptom is explained by the mechanisms you just learned before assuming it is. Recently-taught mechanisms are the ones you over-apply.

Week 3

The contrast ratio is a model too

3 units · ~2 hr 15 min stated
Read~45 min

Unit 9 · Where 4.5:1 came from

This unit produces

A written provenance chain for the number your linter enforces — every step from "4.5:1" back to whatever evidence is underneath it — with the steps you personally verified marked separately from the ones you took on report. Half a page. It is the hardest artifact in this course and the only one that transfers to every other technical field you work in.

The formula

WCAG 2 defines contrast ratio as Sourced fact S5:

(L1 + 0.05) / (L2 + 0.05)

where L1 is the relative luminance of the lighter color and L2 the darker — the same Y you computed in Unit 3. Range: 1:1 to 21:1. The 0.05 is a constant standing in for ambient light reflecting off the screen; it is what stops black-on-black from dividing by zero and what caps the maximum at 21 rather than infinity.

Three structural properties follow immediately, and they are all consequences of the algebra rather than accidents of implementation Inference:

  • It is symmetric. Swap foreground and background and the number is identical. Dark-on-light and light-on-dark score the same. Human vision does not work this way; the same luminance difference reads differently depending on polarity.
  • Chroma is invisible to it. Y throws away everything except weighted luminance. Two colors of wildly different hue and identical Y score 1:1.
  • The 0.05 compresses the dark end. At the top of the range, 0.05 is negligible next to Y ≈ 1. At the bottom, where Y ≈ 0.005, it dominates the numerator and denominator both — and ratios between dark colors get squashed toward 1. Unit 10 measures this.

The threshold

WCAG's own Understanding document gives the derivation of 4.5:1 explicitly. It is two numbers multiplied Sourced fact S4:

  1. A 3:1 minimum for observers with typical vision, adopted from an ANSI standard.
  2. A factor of 1.5, described as the contrast sensitivity loss associated with visual acuity of 20/40, cited to Arditi & Faye.
3 × 1.5 = 4.5

The same logic yields 7:1 for AAA, aimed at roughly 20/80 acuity Sourced fact S4.

Before going further, note something in the historical record. A 2007 working draft of the same document set the AA threshold at 5:1, explaining that 20/40 "calculates to approximately 4.5:1 which is rounded up to 5 providing a slight additional increase in contrast" Sourced fact S11. The threshold moved. That is normal standards work, and it is also evidence about what kind of number this is: a committee judgment informed by evidence, not a measurement.

Going down one level — and what is there

The discipline that catches the most errors in technical writing is simple: when a source cites evidence rather than containing it, follow the citation. Here, WCAG cites Arditi & Faye (2004) for the 1.5 factor. The full reference in WCAG's own bibliography is a one-page conference abstract: Monocular and binocular letter contrast sensitivity and letter acuity in a diverse ophthalmologic practice, Supplement to Optometry and Vision Science, 81(12S), 287 Sourced fact S6.

In 2021 someone filed an issue against the WCAG repository doing exactly this. They report that the only "1.5" in the abstract appears in the sentence "the regression of log CS on log MAR resulted in an intercept of 1.50 and a slope of −0.63," and that this quantity does not obviously correspond to a 1.5× contrast-sensitivity multiplier for 20/40 vision Sourced fact S6.

Here is exactly how far I got, and where I stopped. I opened the WCAG Understanding document and its bibliography and confirmed both the derivation and the reference. I opened the issue and confirmed its contents. I did not read the Arditi & Faye abstract myself — it sits in a paywalled journal supplement — so I am relying on the issue-filer's report of what it says. And I was unable to retrieve the Working Group's response to that issue. So the honest state of my knowledge is: the derivation as published does not visibly trace to the cited source, according to a reader who checked, whose report I have not independently confirmed, and whose challenge may or may not have been answered.

That sentence is deliberately awkward. It is what a verified claim actually looks like when the verification is incomplete, and writing it out is more useful to you than a clean statement would be.

What this does not establish: that 4.5:1 is a bad threshold. Thresholds can be well-chosen and badly documented. Decades of practice have found 4.5:1 to be a workable floor, and that operational track record is real evidence independent of the derivation. What it establishes is narrower and still important: the number is not a measurement, and treating it as one will lead you to defend it in the wrong places.

Why dark mode is where it hurts

The formula's symmetry and its flare constant were specified when the overwhelming majority of interfaces were dark text on light backgrounds. Dark mode inverts the regime, and puts most of the important color pairs down at the compressed end of the scale.

This is not a fringe complaint. In a W3C mailing-list archive from June 2022, Chris Lilley — an editor of the CSS Color specifications — responded to the claim that WCAG 2 contrast only works against light backgrounds by writing that he found this particular failing of the WCAG 2.1 algorithm especially troubling Sourced fact S7. That is a spec editor at the same standards body, on the record, not a vendor with an alternative to sell.

The alternative, and its actual status

You will encounter APCA — the Advanced Perceptual Contrast Algorithm. It is polarity-aware, accounts for font size and weight, and returns a signed Lc value rather than a ratio. It is the most developed candidate for a replacement.

Its status is widely misreported, so here it is carefully Contested: APCA was included in WCAG 3 working drafts as exploratory content, and was removed in July 2023 under the WCAG 3 process rule that exploratory material which does not gain Working Group support within six months is removed automatically. The current WCAG 3 draft states that the contrast algorithm is yet to be determined. WCAG 3 is not expected to reach Recommendation before roughly 2030 Sourced fact S8.

Meanwhile, a great deal of otherwise-good writing describes APCA as "the WCAG 3 contrast algorithm" or says it "is replacing" the WCAG 2 formula. I found several such articles dated 2026 while researching this unit. They are not lying; they are amplifying — a claim that was true of a 2021 draft propagated forward through secondary sources that never rechecked the primary. Vendor framing applies to some of it and plain staleness to the rest, and the two are hard to tell apart from the outside. Which is the argument for going to the spec text.

Do the thing · gated

Go read w3c/wcag issue #1705 yourself — including any responses, which I could not retrieve. Then write the honest one-sentence status: is the 1.5 factor traceable to Arditi & Faye, was the challenge answered, and what does that change about how you'd cite 4.5:1 in a design review?

Before you look — how confident are you that the challenge was satisfactorily answered?
Recorded — you now know something I don't

Whatever you found, notice what just happened: a course told you a claim, told you the exact boundary of its own verification, and handed you the last step. That is the shape of a claim you can actually use. Compare it to the shape of "studies show 4.5:1 is the accessible threshold," which is what most of the internet offers and which cannot be checked at all.

This is the third threshold. Not "WCAG is wrong" — that would be a fact to memorize, and it would be an overstatement. The crossing is that a number in a standard has a provenance chain, that the chain is walkable, and that you are now someone who walks it.

Derive~40 min

Unit 10 · Equal perceptual steps, unequal ratios

This unit produces

A table you compute showing WCAG contrast ratios for five grey pairs that are identically spaced in perceived lightness. If the ratio were a perceptual measure, the five numbers would be equal. Producing the actual spread is the demonstration.

Everything in this unit is built from two formulas you already have — Unit 3's relative luminance and Unit 9's ratio. No new inputs. The claim comes out of the arithmetic.

Build it

  1. Write greyAtOklabL(target) — binary search an sRGB grey value whose Oklab L equals the target. (You wrote a bisection in Unit 6; this is the same shape.)
  2. For each pair below, generate both greys, compute Y for each, and compute the WCAG ratio.
  3. Every pair is separated by exactly ΔL = 0.20 in Oklab.
Numeric · gated · run your own code

What WCAG contrast ratio do you get for the pair Oklab L = 0.25 vs L = 0.05? Two decimal places.

Confidence before reveal:

1.31. Here is the full table Inference — my computation, reproducible from the two sourced formulas:

Pair (Oklab L)ΔLapprox. hexWCAG ratio
0.95 → 0.750.20#eeeeee / #adadad1.92
0.75 → 0.550.20#adadad / #7171712.18
0.55 → 0.350.20#717171 / #3a3a3a2.33
0.35 → 0.150.20#3a3a3a / #0b0b0b1.74
0.25 → 0.050.20#212121 / #0000001.31

Identical perceptual separation, ratios from 1.31 to 2.33 — a spread of 1.8×. And the shape matters more than the spread: the ratio peaks in the middle of the range and falls off sharply at the dark end. The 0.05 flare constant is doing exactly what the algebra predicted in Unit 9.

Read the practical consequence off the last row. Two greys separated by a fifth of the entire perceptual lightness scale — a very visible difference — score 1.31:1. Every threshold in WCAG 2 would reject that pair for any purpose. In a dark theme, that is not an edge case; that is your surface, your elevated surface, and your border.

Delayed retrieval · Week 1 · gated

Without scrolling back: in Unit 3 you computed relative luminance for pure blue #0000FF and pure yellow #FFFF00. Roughly what is the ratio between those two luminances?

Confidence:

0.9278 / 0.0722 ≈ 12.9×. If you had to reconstruct it rather than recall it — blue contributes only the 0.0722 coefficient, yellow contributes 0.2126 + 0.7152 — that reconstruction is worth more than the recall would have been.

Now connect it forward: because the ratio formula sees only Y, blue text on yellow scores about 8:1 and passes everything, while blue text on a mid-grey of identical luminance scores 1:1 and is genuinely illegible. The formula gets the first case right by accident and the second right on purpose, and it has no way to tell you which is which.

Build~50 min

Unit 11 · Audit your own tokens

This unit produces

Two things: an audit script that reports every text pair in your token set with its WCAG ratio and its Oklab ΔL, side by side — and a rewritten contrast-policy.md that replaces the one sentence you wrote in Unit 1.

No worked example this time, and no skeleton. You have every formula you need. This is a specification; build to it.

Specification

  1. Input: your token set, as pairs that will actually appear together as foreground and background — not the cross product. In each theme separately.
  2. For each pair, output: WCAG 2 contrast ratio to two decimals; Oklab ΔL to three decimals; and the AA verdict at 4.5 (normal text) and 3.0 (large text and UI components).
  3. Sort by WCAG ratio ascending.
  4. Flag any pair where the two measures disagree in direction — that is, pair X has a higher WCAG ratio than pair Y but a lower ΔL. Those are the interesting rows.

Everything you need: lin() and Y from Unit 3, the Oklab forward transform from Unit 4, the ratio from Unit 9.

Then write the policy

Replace contrast-policy.md. It has to answer four questions, and it has to be something a colleague could apply without asking you:

  1. What we conform to, and why. If the answer is "WCAG 2 AA because it is what procurement, the EAA, and every automated checker reference" — say that. It is a good reason and it is a different reason than "because 4.5:1 is the legibility threshold."
  2. Where we hold a higher bar than the standard, and where. Unit 10 gives you the argument: the ratio compresses at the dark end, so a dark theme that clears 4.5 by a hair is in worse shape than a light theme that clears it by a hair.
  3. What we do about the pairs the ratio can't see. Chroma differences, borders between two dark surfaces, disabled states.
  4. What would change our mind. Name the event. "If WCAG 3 adopts a contrast algorithm, we re-audit." Naming it converts a standing argument into a scheduled task.
Artifact · gated

Paste the two or three most surprising rows from your audit — the disagreements, or anything that passed when you expected it to fail. Then say what you changed as a result, if anything.

Confidence that your token set is in good shape:
Recorded

Two patterns show up in almost every audit of a real token set Inference, offered after the fact so they didn't bias your run:

  • The dark theme's secondary text is the weakest link, and it usually passes. It clears 4.5 with a margin that would be comfortable in the light theme and is not comfortable at Y ≈ 0.02, for the reason Unit 10 measured.
  • Borders and dividers are unaudited, because most linters only check text. A 1px rule between two dark surfaces frequently sits below 1.5:1 and is doing real structural work in the layout.

If neither showed up in yours, either your tokens are unusually well-built or your pair list was too narrow. Check the second possibility first.

Week 4

Defend it

1 unit · ~1 hr 30 min stated
Capstone~90 min

Unit 12 · The rebuild and the rationale

This unit produces

The finished versions of all three Unit 1 artifacts, plus a fourth: rationale.md, the document that makes the other three defensible. No scaffold, no worked example, no reveal that hands you an answer. A rubric, and three retrieval items that are not adjacent to anything.

Go back to Unit 1. Rebuild all of it.

The rubric

ArtifactHas to
tokens.cssChroma varies with lightness and hue, by a stated rule. Every value verified in-gamut for your declared target, or deliberately out with the mapping strategy named.
gamut.mdStates the target gamut. States the mapping strategy and where it runs — build time or browser. Shows the ordering check: no two adjacent steps swap after mapping.
contrast-policy.mdThe four questions from Unit 11, answered so someone else can apply it without you.
rationale.mdBelow.

rationale.md — the actual test

One page. Five headings:

  1. What we optimized for. Lightness fidelity, chroma richness, wide-gamut reach, ordering stability — you cannot have all four. Name the ranking.
  2. What we gave up to get it. Specifically, with a color or a step named.
  3. What we're taking on authority, and from whom. Every load-bearing claim under your palette, with its source and its type. Oklab's fit — including the part that comes from CAM16 rather than from people. The CSS mapping algorithm's JND constant. The 4.5:1 derivation. Mark each one the way this course marked its claims.
  4. What would falsify this. The observation that would tell you the palette is wrong, not just unfashionable.
  5. What we didn't do. Your own non-goals. If writing them feels awkward, the scope isn't bounded yet.

Section 3 is the one

Sections 1, 2, 4 and 5 are design-doc craft you probably already have. Section 3 is what this course was for, and it is uncomfortable to write, because writing it means discovering how much of your palette rests on things you have not checked. That discomfort is the correct output. A rationale with a short section 3 and no marked uncertainty is not a confident document; it is an unaudited one.

Delayed retrieval · Week 2 · gated

Without scrolling: at Oklab L = 0.50, which hue has the most available chroma in sRGB, and roughly how much more than the least?

Confidence:

Blue at 0.281 against cyan (210°) at 0.086 — a factor of 3.3. And blue is the hue that collapses hardest as lightness rises: 0.074 at L = 0.85. If your brand is blue, your light steps have almost no chroma available and no amount of authoring will change that.

Delayed retrieval · method choice · gated

A colleague proposes: "Let's just define every token by its WCAG ratio against the background instead of by lightness. Then contrast is guaranteed and we skip the color space stuff." What is the strongest single objection?

Confidence:

The proposal inverts the dependency: it makes the palette a function of an accessibility floor rather than checking the palette against one. Unit 10 is the direct refutation — equal steps in ΔL give unequal ratios, so equal steps in ratio give unequal ΔL, and the distortion is worst exactly where dark themes live.

Option 2 is worth naming as wrong: relative luminance is defined from any RGB space's linearization, and the WCAG formula applies fine to P3 arithmetic. The problem is not the gamut; it is the scale.

Final · gated

Paste your rationale.md section 3 — "what we're taking on authority." Then answer one question honestly: which claim in it are you least comfortable leaving unchecked?

Confidence you could defend this palette to a skeptical engineer:
Course complete

Whatever claim you named, that is your next piece of work, and you now have the method for it: open the source, confirm it says what it is cited for, ask whether it contains evidence or cites it, and search specifically for the refutation.

The three thresholds, restated so you can check yourself against them:

  1. "How light is this color" is not a question until you say for what. Lightness is a model, and models have provenance.
  2. Gamut mapping is a spending decision, not a correctness procedure. Every strategy pays in chroma, lightness, or hue, and the CSS default is a compromise chosen for interoperability.
  3. A number in a standard has a citation chain, the chain is walkable, and walking it changes what you can honestly claim.

Scroll up. The diagram should be full. Note what is still outside the triangle.

Your calibration

Confidence you predicted before each reveal, against how you actually did on the items that have a right answer. The gap is the only feedback a solo learner gets that isn't self-report.

Sources

Every claim marked Sourced fact in this course keys to one of these. Tier 1 means primary — the source contains the data, proof, or original formulation. Tier 2 means canonical with a track record. Where I did not personally open something, it says so.

  1. Tier 1 · opened and read
    Björn Ottosson, A perceptual color space for image processing (Oklab), 2020, with a 2025 introduction. bottosson.github.io/posts/oklab. Source of the M1/M2 matrices, the linear-sRGB coefficients, the three fitting datasets, the γ = 1/3 decision, and the author's own caveat that the lightness and chroma data were CAM16-generated. I verified my implementation against his published XYZ→Oklab test table before using any of it. Note: self-published, not peer reviewed. It is Tier 1 because it is the original formulation and is verifiable by derivation, which is the strongest thing a source can be in a formal field.
  2. Tier 1 · opened
    W3C, CSS Color Module Level 4. w3.org/TR/css-color-4. CIELAB/OkLCh definitions, ΔEOK as plain Euclidean distance, the CIELAB blue-hue-curvature discussion, chroma ranges.
  3. Tier 1 · opened
    CSS gamut mapping algorithm pseudocode, W3C public-css-archive, July 2022. lists.w3.org. JND = 0.02, ε = 0.0001, OKLCh working space, the clipped-comparison early exit.
  4. Tier 1 · opened
    W3C WAI, Understanding Success Criterion 1.4.3: Contrast (Minimum). w3.org/WAI/WCAG22. The 3 × 1.5 = 4.5 derivation and the 20/40 and 20/80 rationale.
  5. Tier 1 · opened
    WCAG 2 normative definitions of relative luminance and contrast ratio: the sRGB linearization, the 0.2126/0.7152/0.0722 coefficients, and (L1+0.05)/(L2+0.05).
  6. Tier 1 for the issue · Tier 3, not opened for the abstract
    w3c/wcag issue #1705, "1.4.3 Minimum Contrast, 1.5 multiplication factor — from which research paper?", 2021, reporting that the only 1.5 in Arditi & Faye (2004) is a log-CS-on-log-MAR regression intercept. The underlying reference — Arditi, A. and Faye, E. (2004), Monocular and binocular letter contrast sensitivity and letter acuity in a diverse ophthalmologic practice, Supplement to Optometry and Vision Science, 81(12S), 287 — appears in WCAG's own bibliography, which I confirmed. I did not read the abstract itself. I also could not retrieve the Working Group's response to the issue. Unit 9 asks you to close both gaps.
  7. Tier 1 · opened
    Chris Lilley (CSS Color spec editor), comment on csswg-drafts, W3C public-css-archive, 5 June 2022. lists.w3.org. On the dark-mode failing of the WCAG 2.1 contrast algorithm.
  8. Tier 3 · opened · status tracker
    Adrian Roselli, WCAG3 Contrast as of April 2026. adrianroselli.com. Quotes the current WCAG 3 draft text ("the contrast algorithm used in WCAG 3 is yet to be determined") and the July 2023 removal of exploratory content. Not a primary spec, but it quotes and links the primary text, and it is the most reliable status summary I found. Corroborated against several independent 2026 sources reporting the same removal date and draft status.
  9. Tier 1 · opened and read
    Chris Wyman, Peter-Pike Sloan & Peter Shirley, Simple Analytic Approximations to the CIE XYZ Color Matching Functions, JCGT 2(2), 1–11, 2013. jcgt.org. The multi-lobe piecewise-Gaussian fit and its error table. Used to draw the spectral locus in the progress meter; I validated it against known locus chromaticities before use.
  10. Tier 2 · standard not opened, values verified numerically
    sRGB primary and white-point chromaticities per IEC 61966-2-1 / ITU-R BT.709. The standards are paywalled and I did not read them. I verified the values by confirming that the standard linear sRGB → XYZ matrix reproduces R (0.640, 0.330), G (0.300, 0.600), B (0.150, 0.060) and D65 (0.3127, 0.3290) exactly. Display P3 and Rec. 2020 primaries are quoted from the same lineage and were not independently verified.
  11. Tier 1 · opened
    W3C, Understanding WCAG 2.0, working draft of 10 September 2007, SC 1.4.3. The historical record of the AA threshold at 5:1 before it settled at 4.5:1.
  12. Single study · preprint · not load-bearing
    An arXiv preprint proposing "Oklch+" reports Oklab scoring STRESS = 47.35 on the COMBVD color-difference dataset against CIEDE2000's 29.13 — i.e. Oklab is substantially worse at predicting color differences, which is a different objective from the one it was fit for. I read the abstract and introduction only. Listed because a refutation search turned it up and suppressing it would be exactly the citation bias this course warns about. It is consistent with Ottosson's own framing in S1 and adds a number to it; it is not the basis for any claim above.

What I searched for and didn't find

Refutation searches run while building this: criticism of Oklab (found S12 and the CIELAB-blue-curvature discussion in S2); criticism of the WCAG 2 contrast formula (found S6 and S7); the status of APCA as of 2026 (found S8, and found multiple 2026 articles asserting the opposite of the spec text, which is why Unit 9 discusses amplification). I did not find a peer-reviewed evaluation of Oklab against human data for lightness and chroma — which, given Ottosson's own caveat in S1, may be because one does not exist. If you find one, that is a correction worth making to this course.