Gradient Ramp Developer Reference
Maintain the gradient ramp: four spaces and the six routes through them, per-step CIEDE2000 and the evenness that answers a different question, gamut mapping by chroma bisection with a tolerance set by measurement, and the contrast figure that used to pass text its own algorithm fails.
Overview
The gradient ramp is a self-contained browser runtime. It interpolates between two colours in one of four spaces, measures the perceptual difference between adjacent steps, and checks what can be read on top of them. Nothing is uploaded and no network call is made after the page loads.
Like the collage rebuild before it, most of this engine was already right — and that was established by measurement before anything was touched. The shipped functions were lifted out of their closure and run against published data: CIEDE2000 against all 34 pairs from Sharma, Wu & Dalal (2005), OKLab against Ottosson’s own reference values, WCAG 2 contrast against the figures it is usually demonstrated with. All of it passed, and all of it is carried across unchanged.
Two things were wrong, and both were claims about what the arithmetic is rather than the arithmetic itself. One of them mattered a great deal: the contrast readout passed text that the algorithm it named would fail.
The arithmetic lives in js/tool/gr/gr-core.js, which touches no
DOM and is what the test suite runs. The provenance of every figure lives in
js/tool/gr/gr-sources.js. The page itself holds no numbers.
1. Code map
+| File | Holds |
|---|---|
js/tool/gr/gr-core.js |
Everything numeric and no DOM: sRGB, HSL, OKLab/OKLCH,
CIELAB, CIEDE2000, WCAG 2 contrast, APCA, the CVD matrices,
the six interpolation routes, gamut mapping, ramp building
and ramp measurement. Installs as
window.AAGRCore. |
js/tool/gr/gr-sources.js |
The dataset register: fifteen entries, each
measured, computed,
choice or absent, plus the four
corrections and what each one printed. The page derives its
counts and its tab badge from counts(). |
js/tool/m-gradient-palette.js |
The DOM, the hue circle and the events. |
js/tool/m-gradient-palette-views.js |
The Spaces, Contrast, Data, Export and Reference renderers. Holds no arithmetic. |
css/gradient-ramp-shell.css |
The workbench shell, ported from
css/collage-studio-shell.css, plus the ramp, the
difference bars, the hue circle and the correction
panels. |
tests/colorimetry/gr-harness.js |
Loads the shipped files under a minimal global, and carries the published data they are checked against: the Sharma set, Ottosson’s OKLab values, an independent APCA, and Viénot’s protanopia matrix for telling the CVD models apart. |
2. What was found, and how
+The method matters here, because it is what separated the four findings from the much larger set of things that turned out to be fine. The functions were private to an IIFE, so they were extracted by source text — verbatim, not retyped — and evaluated together in a sandbox, then run against published reference data.
What passed
- CIEDE2000 against all 34 published pairs from Sharma, Wu & Dalal (2005): worst disagreement 0.000042. The mean-hue term uses the correct split form — three sibling tools on this site had the naive average and pass every pair that does not straddle the hue wrap.
- OKLab against Ottosson’s reference values for white and the three sRGB primaries.
- WCAG 2 contrast: 21.00 for black on white, 4.54 for
#767676, 7.00 for#595959. - The OKLCH round trip, exact for every colour tried.
- The batch comparison, which genuinely built and measured all 24 of its combinations rather than predicting them.
- “25+ curated presets” — 25 exactly.
What did not
- The APCA figure, which used a scale of 161.8 instead of 1.14 and had neither of its two clamps. §3.
- The colour-blindness citation, which named Brettel, Viénot & Mollon (1997) for matrices from Machado, Oliveira & Fernandes (2009). §4.
- “6 interpolation spaces”, which counted routes. §5.
- “Chromaticity paths”, over a panel that plots chroma against hue — and did so on a straight axis, so a ramp crossing the hue wrap drew a line across the whole plot.
3. The contrast figure
+This is the finding the rebuild exists for. The function as it shipped:
if (Yb > Yt) {
var S = Math.pow(Yb, 0.56) - Math.pow(Yt, 0.57);
return S * 161.8 + (S > 0 ? 0 : -0);
} else {
var S2 = Math.pow(Yb, 0.65) - Math.pow(Yt, 0.62);
return S2 * 161.8 + (S2 < 0 ? 0 : -0);
}
The four exponents are APCA’s. Everything else is not:
- the scale is 161.8, which is φ × 100, where the published algorithm uses 1.14;
- there is no soft clamp on near-black luminance
(
blkThrs 0.022,blkClmp 1.414), so black enters the power function as a literal zero; - there is no low-contrast clip, so a pair with almost no contrast reports a small non-zero Lc rather than nothing;
- and
+ (S > 0 ? 0 : -0)adds zero or negative zero. It does nothing at all, while reading like the clamp it is not.
Measured against the published algorithm:
| Text on background | It printed | APCA gives | Out by |
|---|---|---|---|
| black on white | 161.80 | 106.04 | 55.76 |
| white on black | −161.80 | −107.88 | 53.92 |
#767676 on white | 105.41 | 71.57 | 33.84 |
#888888 on white | 93.33 | 63.06 | 30.27 |
#111111 on #222222 | 6.81 | 0.00 | 6.81 |
The two middle rows are greys people really use for secondary text, and both were reported about a third high. Across all 2,704 grey-on-grey pairs at a step of five, 520 cleared the tool’s own Lc 60 threshold that real APCA fails. A contrast checker that fails open is worse than none, because it is consulted and believed.
The tell
Look at the first two rows: exactly plus and minus the same number. APCA gives the two polarities different exponents on purpose — the eye does not treat dark-on-light and light-on-dark alike — so a symmetric answer cannot be APCA whatever its scale factor is. That property is now a test: “APCA is ASYMMETRIC, which is the tell the old one failed”. If a change ever makes the two polarities negatives of one another again, it is wrong.
apcaLegacyLc() is kept in the core deliberately. It
reproduces the old figure so the page can show the two side by side rather
than assert a difference, and so the 520 can be re-counted rather than
quoted.
4. The citation that named the wrong paper
+The reference list carried Brettel, H., Viénot, F. & Mollon, J.D. (1997) — Computerized simulation of colour appearance for dichromats, and the research panel said “CVD simulation (Brettel)”. The matrices are the severity-1.0 set from Machado, Oliveira & Fernandes (2009).
These are different models by different authors. Brettel’s method
projects onto two half-planes in LMS space and is not a single 3×3
matrix at all, so no set of nine numbers could have been an instance of it.
Checked by running both on pure red in linear light:
Viénot’s 1999 protanopia matrix gives #5E5E0D
and this code gives #6D5F00.
The simulation itself is correct — the matrix is applied in linear light, which is the part implementations most often get wrong — so it stays and only the name on it changed. A real method named beside code that does not perform it lends that code the method’s authority, and a reader checking one against the other has no way to tell which is the truth.
A note on testing this. The obvious check — simulate pure red and compare against the encoded-value shortcut — cannot work: red is (1, 0, 0) in both encoded and linear, so the two agree. The first version of that test passed while the defect was live, and a mutation run caught it. The test now uses a mid-tone and asserts the two candidates differ before asserting which one the code matches.
5. Four spaces, six routes
+
The spaces are sRGB, linear RGB, HSL and OKLCH. Two of them are offered
with a short and a long way round the hue circle, and the badge that said
“6 interpolation spaces” was counting those routes.
spaceCount() and routeCount() exist so the page
cannot get this wrong again, and a test asserts that the six routes cover
exactly four spaces.
How much further is the long way?
It depends entirely on the two ends, which is worth knowing before writing a number into any copy. Measured, mean ΔE₀₀ per step over a 12-step ramp:
| Ends | Hues apart | Short | Long | Ratio |
|---|---|---|---|---|
#1F6F4A → #3AA06B | 2° | 1.57 | 14.25 | 9.1× |
#C94F2E → #D99A2B | 40° | 2.71 | 14.97 | 5.5× |
#0B3D5C → #F2B134 | 163° | 8.26 | 8.71 | 1.06× |
When the hues are nearly opposite the two journeys are almost the same length. An early draft of this page asserted “three times the distance” for a nearly-antipodal pair, which is off by a factor of three; measuring it produced a better fact than the one that was assumed.
The route table
compareRoutes() builds all thirty combinations and measures
each. The winner is now the measured minimum rather than a fixed
threshold: the table this replaces starred every row whose evenness fell
below 0.5 and headed the column “best”, so several rows could
wear it at once or none could. It is also withheld on a tie, and a test
covers that.
6. Mean and evenness
+
analyseRamp() returns both, and they answer different
questions. The mean says how far apart the steps are; the evenness —
the standard deviation of the per-step differences — says whether
they are equally far apart, which is usually what a ramp is being
asked.
They move independently, and that is demonstrable rather than asserted: changing the easing on one route leaves the mean almost untouched while the evenness can triple, because easing decides where the steps land rather than what the ramp is. A test asserts exactly that.
Evenness is a standard deviation, not a variance. A mutation that dropped the square root survived the first run of the suite, because squaring preserves the ORDER of a set of ramps and every comparison still passed. What it does not preserve is the scale, and the number is printed beside the mean as though the two were comparable. The test now asserts it scales linearly.
7. Gamut mapping
+
Interpolating in a polar space asks for colours sRGB does not hold,
particularly in the middle of a ramp between two saturated ends.
oklchToSrgbGamut() reduces chroma until the colour fits and
leaves lightness and hue where they were, by bisection, twenty times. The
obvious alternative — clipping each channel independently — is
one line shorter and moves the hue as well; a test measures that it does.
The result carries mapped, so the page can report how many
steps were moved rather than moving them quietly, and the ramp marks them
in the strip.
The tolerance is set by measurement
GAMUT_EPS is 1e-4, and the value is not
arbitrary. Converting a colour to OKLCH and back lands it up to
1.7e-6 outside [0, 1] at the gamut corners — pure
yellow is the worst — so a tighter test reports the sRGB primaries
themselves as out of gamut, and the page then claims it moved colours it
did not. One 8-bit step is 3.9e-3, so 1e-4 is forty times below anything
that can change a byte and sixty times above the float noise. Both
failure directions are covered by tests.
8. The register
+| Status | Means | Count |
|---|---|---|
measured | read off the ramp as built | 4 |
computed | a published formula this page implements in full | 6 |
choice | this tool’s own default, standing in for a judgement | 4 |
absent | named on the page and not held | 1 |
The distinction that matters here is between computed and choice. A formula counts as computed only if this page runs all of it, and the APCA entry sat in exactly that gap before the rebuild: the right citation, the right exponents, and three constants missing. A test asserts every APCA constant is present and that the implementation agrees with an independent one written from the published values.
The one absence is colour-vision-deficiency severity below 1.0. The matrices are the full-dichromacy set, so there is nothing between normal vision and total — and anomalous trichromacy is far more common than dichromacy, so the case the page cannot show is the more likely one. What it would take is the severity-parameterised matrices from the same paper, published in eleven steps: a table and a slider rather than a new method. What would not be honest is a severity control that interpolated between the identity and the 1.0 matrix, because that is not what the model does.
The four corrections live in CORRECTED rather than in the
register under any status, because nothing was removed — the
simulation, the route table and the plot all stayed. Each carries the
reading it used to give, and the tests re-measure those readings rather
than trusting them.
9. Tests, and proving them
+node --test "tests/colorimetry/gr-*.test.js"
64 tests across two files. gr-reference.test.js covers the
arithmetic against published data; gr-sources.test.js checks
the register against the code it describes, including that every figure
quoted in a note is re-measured rather than trusted.
The suite was proved by defect injection
84 deliberate defects were injected into the core and the register, each run against the whole suite. The first pass caught 73 and nine survived — among them the CVD linear-light test that pure red could not distinguish, the APCA and WCAG band boundaries, the sRGB breakpoint, a gamut tolerance loose enough to admit a visible error, ease-in and ease-out swapped, and the missing square root on evenness. Seven tests were added and two mutant anchors fixed; the final pass caught 82 of 84 with every anchor applied.
The two survivors are equivalent mutants, and that was proved
rather than assumed. Removing the deltaYmin guard
changes no output because loClip already returns 0 for every
pair inside it — measured: #808080 on
#818181 gives S = 1.22e-2 against a clip of 0.1. Removing the
"none" early return changes nothing because
CVD_MATRICES["none"] is undefined and the
!m guard returns the colour anyway. Both are recorded as such
in the mutation script, and both stay in the code — the guard
because the published algorithm specifies it.
The mutation script reports an anchor it can no longer find as an error rather than as a survivor: a run that silently patches nothing reports a perfect score while testing nothing. It also retries every write, because OneDrive holds transient locks on this tree and a failed write can otherwise leave a mutated file on disk.