Skip to main content
Auric Artisan · Documentation

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.

Published: June 6, 2026 Updated: June 6, 2026 Category: Reference Author: Chirag Bansal
Back to Documentation Auric Artisan Home

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.

Table of contents

  1. Code map
  2. What was found, and how
  3. The contrast figure
  4. The citation that named the wrong paper
  5. Four spaces, six routes
  6. Mean and evenness
  7. Gamut mapping
  8. The register
  9. Tests, and proving them

1. Code map

+
FileHolds
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

  1. The APCA figure, which used a scale of 161.8 instead of 1.14 and had neither of its two clamps. §3.
  2. The colour-blindness citation, which named Brettel, Viénot & Mollon (1997) for matrices from Machado, Oliveira & Fernandes (2009). §4.
  3. “6 interpolation spaces”, which counted routes. §5.
  4. “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 backgroundIt printedAPCA givesOut by
black on white161.80106.0455.76
white on black−161.80−107.8853.92
#767676 on white105.4171.5733.84
#888888 on white93.3363.0630.27
#111111 on #2222226.810.006.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:

EndsHues apartShortLongRatio
#1F6F4A → #3AA06B2°1.5714.259.1×
#C94F2E → #D99A2B40°2.7114.975.5×
#0B3D5C → #F2B134163°8.268.711.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

+
StatusMeansCount
measuredread off the ramp as built4
computeda published formula this page implements in full6
choicethis tool’s own default, standing in for a judgement4
absentnamed on the page and not held1

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.