Here are two greys. Each one sits at a WCAG 2.x contrast ratio of exactly 4.50 against its background, which is the AA threshold for body text.
| colour | ground | WCAG 2.x | APCA Lc | |
|---|---|---|---|---|
| on white | #777777 | #FFFFFF | 4.48 | 71.1 |
| on near-black | #797979 | #090B0E | 4.53 | 31.3 |
WCAG says these two pairs are equally legible. APCA puts forty points between them. The first is usable for large or bold text; the second is at the floor where you may only put placeholder and disabled states. If you have ever built a dark theme, checked every pair against 4.5:1, shipped it, and then found the secondary text genuinely hard to read, that table is the reason.
The same thing shows up with one colour and two grounds. #767676 is the canonical grey that just clears AA on white:
#767676 on #FFFFFF WCAG 4.54 Lc 71.6
#767676 on #090B0E WCAG 4.34 Lc 29.9
WCAG moves by 0.2 and stays in the same neighbourhood. APCA moves by 42 and changes its verdict entirely. The APCA authors are blunt about why: WCAG 2.x contrast ratios "cannot be used for guidance designing dark mode". The ratio is a simple quotient of relative luminances with a flat +0.05 offset, and near black that offset dominates, so the formula stops tracking what an eye does.
I have been building an operations panel with a near-black ground, and this is the constraint the whole palette had to be derived under. What follows is the method and the numbers it produced. Every value below comes out of a 150-line Python script that regenerates the palette from three rules, prints the contrast next to each value, and is checked into the repo beside the CSS.
Rule one: grounds are chosen by lightness
Nobody reads a background. A ground has one job, which is to sit at a predictable distance from the grounds next to it so that a raised card looks raised without a border doing the work.
OKLCH makes this trivial in a way hex and HSL do not. Its L is perceptual lightness, so equal steps in L look like equal steps. Five surfaces, one hue, one small chroma, and the spacing is chosen and not discovered:
NH = 250 # cool graphite
GROUNDS = [('surface', 0.150, 0.007),
('raised', 0.192, 0.008),
('hover', 0.232, 0.009),
('border', 0.280, 0.010),
('border-strong', 0.360, 0.011)]
--surface #090B0E L=0.150
--raised #121518 L=0.192
--hover #1A1E22 L=0.232
--border #25292E L=0.280
--border-strong #393E43 L=0.360
The chroma creeps up with the lightness because a neutral at L 0.36 with the same chroma as one at L 0.15 reads as flatter, and this hue is doing quiet work. It is a cool graphite at hue 250. The first version of this palette used a warm amber-brown at hue 68, and it lost to a one-line piece of feedback after the panel was actually built and looked at: the ground was the part that could not be settled with. A cool near-neutral is also what leaves room for one warm accent to mean something is wrong.
Rule two: text is chosen by the contrast it must deliver
This is the inversion that matters. Do not pick a lightness and check the contrast. Pick the contrast and solve for the lightness.
Every text tier exists to do a specific job, and each job has a legibility floor. APCA gives those floors as Lc values: 90 for body text at any size, 75 for content once size or weight compensate, 60 for large or bold only, 30 as the floor below which nothing may be text at all. So the tiers are declared as targets, and a binary search finds the lowest lightness that reaches each one.
def solve_lc(target, C, h, bg):
"""Lowest OKLCH lightness whose APCA contrast on `bg` reaches `target`."""
lo, hi = 0.30, 0.99
for _ in range(60):
mid = (lo + hi) / 2
if apca(hexof(mid, C, h)[0], bg) < target:
lo = mid
else:
hi = mid
return hexof(hi, C, h)[0], round(hi, 3)
TEXT_TIERS = [('dim', 38, 0.011), ('faint', 60, 0.011),
('muted', 78, 0.010), ('text', 90, 0.008)]
tier hex L WCAG Lc what it may be used for
--dim #82888D 0.622 5.49 38.0 disabled and placeholder only
--faint #ACB2B8 0.759 9.21 60.0 large or bold only
--muted #CBD1D6 0.857 12.79 78.0 content, with size or weight
--text #E0E5E9 0.918 15.53 90.4 body text, any size
Look at --dim. WCAG 5.49, comfortably past AA, and it is the token this palette forbids you from using for anything a user has to read. That single row is the argument for the whole method. Under a WCAG-only workflow it would have been a perfectly respectable body-text grey.
The first draft of this palette was picked by eye, and it is worth recording what "by eye" actually produced. --muted, the tier carrying most secondary content, landed at Lc 55. --faint landed at Lc 33. Both looked fine on my monitor at night. Both were under the floor for the job they had been given, and neither was going to announce that in a screenshot.
Rule three: accents are uniform in contrast, not in lightness
The design rule I wanted was that no accent hue shouts louder than another. Green for money, cyan for interaction, amber for a problem, and none of the three grabbing the eye first purely because of its hue.
The obvious implementation is to generate all three at the same OKLCH lightness and chroma. That is what "perceptually uniform" seems to promise. Here is what it actually gives on this ground:
uniform lightness, L=0.840, C=0.134
jade h=158 #76E5A9 Lc 77.9
cyan h=202 #34E4F0 Lc 77.9
amber h= 70 #FFBB69 Lc 73.4
Amber arrives four and a half Lc points short. OKLCH lightness is uniform in appearance of lightness, and contrast against a specific background is a different measurement. Shouting is contrast, so the target has to be contrast:
uniform contrast, Lc=78
jade h=158 #77E6A9 L=0.842 Lc 78.4
cyan h=202 #35E4F0 L=0.841 Lc 78.0
amber h= 70 #FFC683 L=0.862 Lc 78.2
Amber is pushed up to L 0.862 to earn the same contrast the other two get at 0.841. The difference is small and it is exactly the size of difference that is invisible in review and noticeable in use.
The Lc target is 78 rather than 60 because these accents are not decoration. They appear as 14-pixel table cells and inline status text, and an accent you cannot read at body size is a colour that has to be reserved for badges.
The chroma ceiling is set by the worst hue
The accents run at chroma 0.134. That is not a designer's number and it was not chosen; it fell out of the gamut.
sRGB is not a sphere. How saturated a hue can get at a given lightness varies enormously by hue, and OKLCH will happily accept coordinates that have no sRGB colour behind them. Requesting chroma 0.30 at L 0.80 and letting the converter reduce until the colour fits gives:
jade h=158 max chroma in sRGB = 0.186
cyan h=202 max chroma in sRGB = 0.136
amber h= 70 max chroma in sRGB = 0.162
Cyan runs out first. If each hue took its own maximum, jade would be visibly more saturated than cyan and the uniformity rule would break on the one axis I had not thought to check. So the palette runs every accent at the minimum of the three ceilings, minus a small safety margin:
ceil = min(oklch_to_srgb(0.80, 0.30, h)[1] for h in HUES.values())
C_FG = round(ceil - 0.002, 3) # 0.134
Add a fourth hue and this number can only go down. That is a real cost of expanding a palette, and it is not one that shows up in a swatch grid.
Three hues, and why the other three were deleted
The palette had six accents. Blue, iris and magenta are gone, and the reason was a two-word verdict on the first build: too many colours.
The argument underneath it is that colour is vocabulary. With six accents, a hue no longer tells you anything, because every hue is just another label and you are reading the text anyway. With three, each one has exactly one job:
- jade — money, and a lead worth visiting
- cyan — interaction: where you are, what has focus, what is a link
- amber — something is wrong, or waiting on a human
A seventh state gets a neutral step, not a seventh hue. Constraining this also had a second effect I did not anticipate: pulling gold and magenta out meant nothing sat near the neutral hue any more, so the amber can do alarm work without competing with the ground.
Why this is a script and not a design file
The palette is a Python file that prints its own table. Running it regenerates every value, and the WCAG ratio and the Lc sit next to each hex in the output.
The practical consequence is that a change which breaks legibility shows up in a diff rather than in a screenshot three weeks later. Nudge a ground half a step darker and every text tier's Lc changes; run the file, and the numbers that moved are right there in the pull request next to the colours that moved. A palette in a design tool cannot tell you that. A palette that is 40 lines of CSS variables definitely cannot.
Where I would not push this
APCA 0.1.9 is a draft. It is the candidate model for WCAG 3, and WCAG 3 is not a standard. If you have a contractual obligation to WCAG 2.1 AA, you still have to meet 4.5:1, and the honest answer is to satisfy both: derive against Lc, then check that nothing fell under the ratio. Every tier in the table above clears 4.5 comfortably, which is the easy direction on a near-black ground.
The thresholds are a reading, not a law. Lc 90 / 75 / 60 / 30 come from the APCA guidance, and the mapping from those numbers to "this token may be used for that" is mine. It is written down in the script, which at least makes it arguable.
This is sRGB only. Display-P3 moves the ceiling and would change the chroma. That is a one-function change and I have not needed it.
None of this makes the palette good. It makes it derived, so that when something looks wrong, there is a rule to argue with instead of a hex value someone once liked.
The full script is 150 lines of Python with no dependencies. It implements the OKLCH to sRGB transform with chroma reduction for gamut fitting, APCA 0.1.9, and WCAG 2.x, and prints the table shown above. Measurements taken 31 July 2026.
Disclosure: the implementation is AI-produced. I specify what it has to do, run it, read the numbers and decide what changes, and I do not hand-write the Python. The three rules, the choice to derive against Lc rather than pick by eye, and the judgement about which tier may carry which text are mine. This article was drafted with AI assistance.