Accessibility Library Developer Reference
Source-level documentation for the contrast map at /library/accessibility/: its three modules, the color maths, the seeded pair corpus, rendering and caching, state, accessibility, the shared API engine and the tests.
Overview
Use this reference when changing the contrast map, its color engine or the corpus module. The page answers "what may I write on this?" for one ground at a time by computing, in the browser, where each WCAG 2.x threshold falls across an OKLCH slice. The same two modules also answer the REST API, so a change here changes both.
1. Code map
+
The page is a static shell plus three ES modules. index.html loads
/js/unified.js for the site chrome and /library/accessibility/contrast-map.js,
which imports the other two. The modules are served as they are, without bundling.
| File | Responsibility |
|---|---|
library/accessibility/index.html | SEO head, the map markup and every id the script reads. Loads css/contrast-map.css and preloads the manifest with <link rel="preload" as="fetch">. |
library/accessibility/contrast-map.js | The page runtime: state, the field and contours, the reading panel, pins, picks, Copy as CSS, the record lookup and the windowed browser. Exports init, buildContours, territory, failClip and COLS. |
library/accessibility/color-engine.js | Pure color functions with no DOM and no state: conversions, WCAG contrast, APCA, vision simulations, Auric SD scoring, the OKLCH-to-sRGB inverse, maxChromaRgb() and contourLightness(). |
library/accessibility/corpus.js | The seeded five-million-pair corpus: configure(), pairStream(), pairForIndex(), enrichRecord(), getRecordByIndex(), recordId(), indexFromRecordId(), datasetInfo(). |
css/contrast-map.css | Layout and theme. Its --cm-* tokens point into the site's --aa2-* deck, so the page follows light and dark mode. |
data/accessibility-db/accessibility.json | The corpus manifest, 2,969 bytes. |
api/lib/handlers/corpus.js | The REST handlers. They import corpus.js, color-engine.js and the manifest, so the API and the page share one engine. |
tests/color-science/color-engine.test.js, corpus.test.js | Node unit tests for the two modules. |
tests/ui/contrast-map.spec.js | Playwright tests for the page. |
library/accessibility/accessibility-library.js, the runtime of the earlier catalogue
page with its tabs, filters, pagination, exports and saved items, is still in the folder, but no
page loads it and no module imports it; users cannot reach it. color-engine.js and
corpus.js were extracted from it. sw.js precaches
/library/accessibility/, the three live modules and the manifest.
2. Page shell and DOM contract
+
contrast-map.js finds everything by id or data attribute, and init()
does nothing if #cm-map is missing. Keep these when editing index.html:
- Masthead:
cm-corpus-countandcm-corpus-seed, filled from the manifest; a[data-aa-credit-hint]slot wherejs/common/tool-credit-hint.jsplaces its API line. - Ground bar: buttons with
data-cm-ground="#RRGGBB"andaria-pressed;cm-ground-custom(input type="color");cm-swap; target buttons withdata-cm-target="t3|t45|t7";cm-territory. - Map:
cm-map(role="slider",tabindex="0"); the field layerscm-fieldandcm-field-dim; SVG polylinescm-line-t3,cm-line-t45andcm-line-t7;cm-pins;cm-caret. - Reading panel:
cm-specimen,cm-ratio,cm-verdict,cm-fg,cm-bg,cm-light,cm-hue,cm-lc,cm-cvd,cm-distance,cm-advice. - Picks:
cm-picks,cm-pick-count,cm-pick-target,cm-pick-ground,cm-copy-css. - Palette:
cm-pin-input(textarea) andcm-pin-rows(table body). - Index:
cm-index-input,cm-index-go,cm-index-result; filter buttons withdata-cm-browse-filter="pass|all";cm-browse-shown,cm-browse-scanned,cm-browse-total;cm-browse(the spacer),cm-browse-win(the window) andcm-browse-note.
Generated elements carry data attributes for the delegated handlers: picks have
data-cm-pick="#hex" and browse cards data-cm-record="index".
render() also writes to cm-target-note, which the current markup does
not contain; setText() skips ids that are missing.
The inline <style> block in index.html still carries
.plib-* and .aa-* rules from the earlier page, and an
#alib-toast element remains in the body. contrast-map.js uses neither.
3. Boot and data flow
+init()runs onDOMContentLoaded, or at once if the document has already loaded, and returns if#cm-mapis missing.bind()attaches pointer and keyboard handlers to the map, one delegated click handler ondocument, and input handlers for the color picker, the lookup box and the palette box.render()draws the field, contours, veil, picks and readout for the default state: ground#1D2A3E, targett45, hue 190, lightness 0.78.bindBrowse()callsbrowseReset()and adds scroll and resize listeners.- The manifest is fetched from
/data/accessibility-db/accessibility.json.configure()applies it, the masthead count and seed are updated, and the browse list is rebuilt only if the seed or count differ from 9001 and 5,000,000. A failed fetch leaves the defaults in place.
The map needs no data: it is computed entirely by color-engine.js. The manifest fetch
is the only network request the script makes; browsing and lookups derive records locally.
4. Manifest contract
+configure(manifest) in corpus.js reads four fields and ignores the rest:
| Field | Use | Default in corpus.js |
|---|---|---|
seed | Starting state of the xorshift32 stream for batch 0. | 9001 |
batch_size | Records per batch; the stream reseeds at each batch. | 50000 |
batch_seed_stride | Added to the seed once per batch number. | 2654435761 |
count | Size of the corpus; lookups at or past it return null. | 5000000 |
The file also carries schema (aa.accessibility-db.compact.v1),
format, source (@auric-artisan/accessibility-gen),
standard_thresholds, auric_sd_weights, cvd_types,
use_cases, apca_use_cases and notes. They describe the
scoring, but the thresholds actually applied are the constants in color-engine.js, so a
change to one must be made in the other.
Changing seed, batch_size, batch_seed_stride or
count changes the colors behind every record id and breaks every citation made so far.
Treat the four values as fixed.
5. Deterministic record generation
+nextXor(s)advances the xorshift32 state (shifts of 13, 17 and 5).batchSeed(batch)returns(seed + batch * batch_seed_stride) | 0; a zero state is replaced with 1 before use.pairForIndex(index)walks from its batch seed past six steps per earlier record in the batch, then reads six bytes ((s >>> 0) % 256): foreground RGB, then background RGB. Random access costs time in proportion to the position within the batch.pairStream(from)is a generator that yields{ index, fg, bg }in order, walking each batch once and reseeding at batch boundaries, so a scan of n records is O(n) instead of O(n²).enrichRecord(index, fg, bg)builds the scored record: hex, HSL, OKLCH and Lab for both colors,contrast_ratio(two decimals),Lc,polarity,use_case_apca,wcag21,wcag30,cvd,cvdMin,cvdMean,auric_score,auric_axes,auric_grade,flex,strictandrecommendation.getRecordByIndex(index)rejects anything that is not an integer inside the corpus, then returns a cached or newly enriched record. The cache holds 1,200 records and drops the oldest first.recordId(index)returnsacc_plus the index in base 36;indexFromRecordId(id)accepts that form or bare digits and returnsnullotherwise.configure(manifest)sets the four numbers and clears the cache;datasetInfo()returns a copy of them.
6. Color maths in color-engine.js
+| Function | What it computes |
|---|---|
relLuminance([r, g, b]) | WCAG 2.x relative luminance: 0.2126 R + 0.7152 G + 0.0722 B after linearising each channel with the 0.03928 threshold. |
contrastRatio(a, b) | (lighter + 0.05) / (darker + 0.05). Symmetric, from 1 to 21. |
rgbToOklab(), rgbToOklch() | OKLab from linear sRGB, and its polar form [L, C, h] with h in degrees. |
oklchToRgb(L, C, h) | The inverse. Returns { inGamut, rgb }: rgb is clamped to 0–255 as floats, and inGamut is false if any linear channel fell outside −0.0005 to 1.0005. |
maxChromaRgb(L, h, cap = 0.32, steps = 14) | Bisects chroma for the most saturated in-gamut color at that lightness and hue. |
contourLightness(h, bgRgb, target, steps = 18) | Bisects lightness for the point where the contrast of maxChromaRgb(L, h) with the ground crosses target. The ground counts as dark when its relative luminance is under 0.18. Returns null when even L = 1 (dark ground) or L = 0 (light ground) misses the target. |
apcaContrast(txt, bg) | APCA Lc from screenLuminance() (a plain 2.4 exponent) with a soft clamp below 0.022, rounded to 0.1. Positive for dark text on a lighter ground, negative the other way; apcaPolarity() names it BoW or WoB. |
cvdScores(fg, bg) | pairDistance() after applyCvd() with each of the eight CVD_MATRICES (applied in linear light), plus cataracts() and lowVision(). Ten keys, each from 0 to 1. |
pairDistance(a, b) | Euclidean RGB distance divided by that of black and white, capped at 1. |
hexToRgb(hex) | Accepts 3 or 6 hex digits, with or without #; returns null otherwise. |
Two linearisation thresholds are in use: 0.03928 in relLuminance(), as in the WCAG
2.x relative-luminance definition, and 0.04045 in sRGBToLinear(), which feeds OKLab
and the vision simulations. For 8-bit input they give identical results, because no 8-bit channel
value falls between them.
The map passes unrounded float RGB from maxChromaRgb() straight into
contrastRatio(), while the hex it displays is rounded. A re-check of that hex can
therefore differ in the second decimal place; the UI test allows 0.05.
7. Standards and Auric SD
+
The map and panel use only contrastRatio(), apcaContrast() and
cvdScores(), against the WCAG 2.x ratios in TARGETS. The helpers below
are called from enrichRecord(), so they reach the page only through the record lookup
(Auric SD score and grade, Recommended for) and reach the API through every record.
| Helper | Rule |
|---|---|
wcag21Verdict(ratio) | AA_normal 4.5, AA_large 3, AAA_normal 7, AAA_large 4.5, non_text 3; level is AAA, AA, AA-large or fail. |
wcag30Verdict(lc) | On |Lc|: bronze body 75, large 60, UI 45, spot 30, non-text 15; silver_body 85; gold_body 90. |
auricSdScore(fg, bg) | 0.30 × min(1, ratio / 7) + 0.30 × min(1, |Lc| / 90) + 0.20 × mean vision distance + 0.10 × min(1, OKLCH lightness gap / 0.5) + 0.10 × (HSL hue difference / 180, weighted by min(1, sum of the two OKLCH chromas / 0.3)), returned out of 100 with one decimal. |
auricGrade(score) | S from 90, A from 80, B from 70, C from 60, D from 50, otherwise F. |
auricFlexibleVerdict(), auricStrictVerdict() | Floors on score, ratio and |Lc| per use. Strict also needs cvdMin: 0.30 for body, 0.25 for large text and UI, 0.20 for non-text. |
pickRecommendation(wcag21, wcag30, strict) | The first match of body-text-strict, body-text-WCAG3, body-text-AA, large-text, ui-only, spot-text, non-text, decorative. |
The WCAG 3 names follow the drafts' APCA tiers; they are labels, not a conformance claim.
8. Rendering the map
+
The map is plain DOM, sized in percentages so it fits any width. COLS = 72 hue columns
(one per 5°) and STOPS = 13 lightness stops per column set its resolution.
- Field:
buildField()computes, once per page, 72 columns of 13 hex stops frommaxChromaRgb()at L = 1 down to 0.renderField()writes each column as a<span>with a vertical linear gradient into both#cm-fieldand#cm-field-dim. The field does not depend on the ground. - Contours:
buildContours(groundHex, groundRgb)returns{ t3, t45, t7 }, each 72 boundary lightnesses ornull, cached by ground hex.polyline()writes them into the SVG, which uses a 0–100 viewBox withpreserveAspectRatio="none"and non-scaling strokes; the SVG is given an explicit width and height because a positioned but unsized SVG squares itself off its width.nullhues are skipped. - Veil:
failClip(line, dark)returns a CSSpolygon()covering the failing side of the current target's line.#cm-field-dimis a greyscale, darkened copy of the field clipped to it; anullhue is dimmed over its full height. - Territory:
territory(line, dark)averages, over the 72 columns, the passing share of the lightness range: 1 − L on a dark ground, L on a light one, 0 fornull. - Readout: the caret color is
chromaAt(light, hue); the panel shows its ratio,verdictFor(), rounded APCA, the minimum ofcvdScores()and the gap to the line in the caret's column;adviceFor()writes the sentence. - Pins:
readPins()takes up to 24 unique#hex values;renderPins()places each at its own OKLCH L and h, tests its real ratio against the target, and draws a.cm__liftsegment to the line for failures. - Picks:
buildPicks()triesPICK_COUNT = 12hues at (i + 0.5) × 30°, sets L to the boundary plus or minusPICK_MARGIN = 0.10, clamped to 0.03–0.97, and drops any pick still under the target.picksAsCss()formats the set as a:rootblock of--groundand--fg-NNwith ratio and hue comments.
render() keys the expensive work on `${ground}|${target}`, kept in
drawnFor. When the key is unchanged — a caret move — only the caret, specimen, rows and
ARIA attributes update. Field, veil, contours, picks, territory and pressed states are redrawn only
when the ground or the target changes; pins also redraw when the pasted list changes.
9. State, events and the URL
+Page state is one module-level object:
| Key | Meaning |
|---|---|
ground | Current ground hex; default #1D2A3E. |
target | t3, t45 or t7; default t45. TARGETS maps each to its ratio (3, 4.5, 7), label and note. |
hue, light | Caret position in OKLCH hue and lightness; default 190 and 0.78. |
pins, pinsDirty | Pasted colors, upper-cased and de-duplicated, and a redraw flag. |
picks | The current pick set. |
field, contours | The cached field and the contours for the current ground. |
- Pointer:
pointerdowncaptures the pointer;pointermovewhile dragging maps the position to hue 0–360 and lightness 1–0 inpickFromEvent()and callsrenderSoon(), which coalesces to onerender()per animation frame. - Keyboard on
#cm-map: arrows step hue by 5° and lightness by 0.02, five times with Shift; Home and End set lightness to 1 and 0. Handled keys callpreventDefault()andrender(). - Clicks: one delegated handler on
documentcoversdata-cm-ground,data-cm-target(which also resets the browser when it is filtered),data-cm-pick,#cm-copy-css,#cm-index-goand#cm-swap.bindBrowse()adds a second fordata-cm-browse-filteranddata-cm-record. - Inputs: the color picker's
inputevent sets the ground; the palette box is debounced by 200 ms intoreadPins(); Enter in the lookup box runslookup(). - Clipboard: Copy as CSS calls
navigator.clipboard?.writeText()and flashes Copied or Copy failed on the button for 1.6 seconds. Where the Clipboard API is absent the call is skipped and the button does not change.
contrast-map.js reads and writes no query string or hash. The site-wide
js/common/url-scroll-state.js, loaded by unified.js, records scroll
position and keyed form controls inside <main> under #p=. Here that
captures #cm-ground-custom, which syncControls() keeps equal to the ground,
and #cm-pin-input; restoring them fires input events, so the ground and the
pasted palette come back. The ground and target buttons have no id, so they are not recorded, and
#cm-index-input is type="search", which that module skips. The target and
caret are therefore lost on reload.
10. The windowed corpus browser
+The index scrolls through the corpus without fetching and without growing the DOM.
- Scanning:
scanTo(want)pulls frompairStream(0)untilbrowse.itemsholdswantentries orSCAN_CHUNK(4,000) records have been examined in that call. Each kept entry is four numbers — index, packed foreground, packed background and ratio — not an enriched record. With the pass filter on, entries under the current target ratio are skipped. - Geometry:
measure()reads the column count from the window's computedgrid-template-columnsand the row height from the first card plus the row gap.paintWindow()works out the visible rows withOVERSCAN= 2 rows each side, sets the spacer#cm-browseto the found rows plusRUNWAY= 4 so there is always more to scroll into, and moves#cm-browse-winwithtranslateY. - Recycling: cards are
<button class="cm__rec">elements reused in place; a card's markup is rewritten only when itsdata-cm-recordchanges. - Scheduling: scroll events go through
paintSoon(), one paint per animation frame; resize re-measures first. - Reset:
browseReset()clears the list, scans columns × 8 entries, paints, measures and paints again. It runs at start, on a filter change, on a target change while filtered, and after the manifest loads if it differs from the defaults. - Opening: a card click resolves the full record with
getRecordByIndex(), sets the ground to its background and the caret to its foreground's OKLCH L and h, renders, and scrolls the map into view.
cardMarkup() sets the ratio's data-pass at a fixed 4.5, not at the current
target, so a 3:1 target shows passing cards between 3 and 4.5 in the failing color.
11. Accessibility behavior
+#cm-mapisrole="slider"witharia-valuemin="0",aria-valuemax="360"andaria-valuenowset to the hue.aria-valuetextcarries the whole reading, for example#00d2ca on #1D2A3E, 7.64 to 1, passes 4.5:1 for body textat the defaults. Keeparia-valuenow: a slider without it is announced with no value.- The reading panel (
aside.cm__read) isaria-live="polite", and everything the map shows is also text there. - Ground, target and browse-filter buttons expose
aria-pressed, kept in step bysyncControls()and the filter handler. The target and filter sets arerole="group"with labels. - The color picker, the palette box and the lookup box have labels that are hidden visually (
.sr) but read by screen readers. - The field, lines, pins, caret and axes are
aria-hidden. --cm-line-strongmaps to the site's control-line token, which is meant for control boundaries under WCAG 1.4.11. The UI test fails on any text under 11px and checks text contrast in both themes.touch-action: noneon the map lets a touch drag move the caret instead of scrolling the page, andprefers-reduced-motionremoves transitions.
12. Performance measures
+chromaAt()memoisesmaxChromaRgb()with lightness quantised to 1/512 and hue to 0.5°, and clears the cache past 60,000 entries.contourCachekeeps contours by ground hex. One ground's three contours cost about 54,000 color conversions, which used to run on every pointer move. The cache is not bounded; each new picker color adds an entry of 216 numbers.- The field is built once; the dim layer reuses the same markup.
- Caret moves skip all ground and target work (
drawnFor) and are coalesced per animation frame. The UI test dispatches 20 pointer moves and fails if any takes 16.7 ms or more. pairStream()makes scanning linear, and filtering uses the raw ratio before any enrichment, so discarded records never pay for ten vision simulations.- The browse window keeps only the rows near the viewport. The UI test scrolls 25 × 1,200 px and requires no request to the manifest or the API, fewer than 2,000 nodes under
#cm-browse, and a card count that stays close to where it started. - The map needs no network. The manifest is preloaded in the head and applied after the first render.
13. The shared API engine
+
api/lib/handlers/corpus.js lazily imports corpus.js,
color-engine.js and the manifest and calls configure() with it, so every
endpoint answers with the page's own functions. The routes are registered in
api/lib/routes.js:
| Route | Page counterpart |
|---|---|
POST /v1/accessibility/map | The contours and territory for a ground. |
POST /v1/accessibility/picks | The picks band; defaults of 12 hues and a 0.10 margin match the page. |
POST /v1/accessibility/audit | "Or bring your own", with a suggested fix for each failure. |
GET /v1/accessibility/corpus | The browse list, with a cursor and a scan budget. |
GET /v1/accessibility/corpus/stats | The measured contrast distribution of the corpus. |
GET /v1/accessibility/corpus/:record_id | The record lookup. |
/corpus/stats is registered before /corpus/:record_id so the parameter
does not swallow it. Parameters, responses and costs are described in
The Accessibility Corpus API
and the API endpoint reference, which
scripts/generate-api-docs.mjs builds from api/lib/catalog.js. A change to color-engine.js or
corpus.js changes the page and the API at once.
14. Testing
+npm run test:color-sciencerunstests/color-sciencein Node.color-engine.test.jschecks contrast against values the token deck states, the OKLCH round trip, a gamut slice without holes, the contour position, a boundary that moves with hue, less room on a mid-tone ground,nullfor unreachable targets, APCA polarity, ten vision conditions, the Auric SD weights and hex parsing.corpus.test.jschecks the manifest values, that the stream matches random access across a batch boundary, id round trips and range checks.npx playwright test tests/ui/contrast-map.spec.js --project=chromiumdrives the page (npm run test:uiruns all oftests/ui). WithoutBASE_URLthe config starts its own server throughtests/website/support/global-server.mjs; the spec blocks service workers.- The UI spec covers: one interface only; layout; 72 field columns and three 72-point contours; SVG sizing; heading and text contrast in both themes; territory by ground and by target; the readout against a recomputed ratio; keyboard control; twelve picks at or above the target; clicking a pick; the CSS on the clipboard; pins and lift lines for a pasted palette; the lookup and its errors; the browse filter, scrolling without data requests, Everything and opening a record; the drag frame budget; and no text under 11px.
- If a manifest value changes, expect the corpus test and the lookup test (
acc_91is#4E1229on#D86062at 4.00:1) to fail: every citation has moved.
15. Extension points
+- A preset ground: add a button with
data-cm-ground="#RRGGBB"and its swatch to the ground bar;syncControls()handles its pressed state. - A target: add an entry to
TARGETS(id,label,ratio,note), a button withdata-cm-target, an SVG polyline with idcm-line-<id>and a legend entry.buildContours()computes one contour per entry, so each target adds 72 contour searches per ground. RevisitverdictFor()if the new ratio needs its own wording. - Resolution:
COLSandSTOPSset what is drawn, and thechromaAt()quantisation is matched to them. Contour cost grows withCOLS. - Picks:
PICK_COUNT,PICK_MARGINandpicksAsCss(). Keep the picks endpoint's defaults in step. - Scoring:
color-engine.jsis shared with the API, and the manifest documents its thresholds. Change the code, the manifest description, the tests and both documentation pages together. - Corpus: leave the four manifest numbers alone; add new record fields in
enrichRecord(). - Reuse:
contrast-map.jsexportsinit,buildContours,territory,failClipandCOLS.
For the user-facing behavior, see the Accessibility Library User Guide. For the generator package the corpus definition comes from, see the Accessibility Gen Developer Reference.