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
No color appearance modeling. CIECAM16, viewing conditions, chromatic
adaptation, surround effects — named once, never used. They are the actual state of the art
and they are out of scope.
No HDR. Everything here assumes SDR, D65, a normally-lit room.
No color vision deficiency simulation. Genuinely important, genuinely
a different course.
No print, no CMYK, no ICC profiles. Screens only.
No claim that OKLCH is correct. It is a fit, with known limits, and
Week 2 makes you find one of them.
No accessibility-compliance advice. This course will show you that
WCAG 2's contrast math is a model with documented failure modes. It will not tell you to
stop conforming to it. Those are different questions and Unit 9 separates them explicitly.
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.
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:
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.0722BSourced 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
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.452Inference — 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.
Color
HSL L
Y
CIE L*
Oklab L
#0000FF blue
50.0%
0.0722
32.3
0.452
#808080 grey
50.2%
0.2159
53.6
0.600
#FF0000 red
50.0%
0.2126
53.2
0.628
#00FF00 green
50.0%
0.7152
87.7
0.866
#FFFF00 yellow
50.0%
0.9278
97.1
0.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.
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 L
H 30°
H 90°
H 150°
H 210°
H 270°
H 330°
0.50
0.201
0.102
0.138
0.086
0.281
0.228
0.70
0.192
0.143
0.193
0.121
0.156
0.314
0.85
0.082
0.174
0.234
0.137
0.074
0.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.
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:
Requested
Clip
Chroma-reduce
CSS GMA
oklch(0.55 0.32 264)
#0044ff
#205cff
#1656ff
→ resulting Oklab L
0.506
0.550
0.537
→ ΔEOK from request
0.0614
0.0716
0.0646
oklch(0.70 0.28 150)
#00c91a
#00be58
#00c248
→ resulting Oklab L
0.724
0.700
0.709
→ ΔEOK from request
0.0553
0.0872
0.0713
oklch(0.80 0.22 90)
#f4b300
#e6b700
#ebb600
→ resulting Oklab L
0.805
0.800
0.802
→ ΔEOK from request
0.0607
0.0565
0.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.
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:
A 3:1 minimum for observers with typical vision, adopted from an ANSI standard.
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
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.)
For each pair below, generate both greys, compute Y for each, and compute the WCAG ratio.
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)
ΔL
approx. hex
WCAG ratio
0.95 → 0.75
0.20
#eeeeee / #adadad
1.92
0.75 → 0.55
0.20
#adadad / #717171
2.18
0.55 → 0.35
0.20
#717171 / #3a3a3a
2.33
0.35 → 0.15
0.20
#3a3a3a / #0b0b0b
1.74
0.25 → 0.05
0.20
#212121 / #000000
1.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
Input: your token set, as pairs that will actually appear together as foreground and
background — not the cross product. In each theme separately.
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).
Sort by WCAG ratio ascending.
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:
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."
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.
What we do about the pairs the ratio can't see. Chroma differences, borders
between two dark surfaces, disabled states.
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
Artifact
Has to
tokens.css
Chroma 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.md
States 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.md
The four questions from Unit 11, answered so someone else can apply it without you.
rationale.md
Below.
rationale.md — the actual test
One page. Five headings:
What we optimized for. Lightness fidelity, chroma richness, wide-gamut
reach, ordering stability — you cannot have all four. Name the ranking.
What we gave up to get it. Specifically, with a color or a step named.
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.
What would falsify this. The observation that would tell you the palette is
wrong, not just unfashionable.
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:
"How light is this color" is not a question until you say for what. Lightness is
a model, and models have provenance.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.