Skip to main content
Auric Artisan · Documentation

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.

Updated: September 26, 2026 Source: library/accessibility/contrast-map.js, color-engine.js, corpus.js Topics: WCAG contours, OKLCH gamut, seeded corpus, windowed browsing Author: Chirag Bansal
Back to Documentation Open Accessibility Library

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.

Table of contents

  1. 1. Code map
  2. 2. Page shell and DOM contract
  3. 3. Boot and data flow
  4. 4. Manifest contract
  5. 5. Deterministic record generation
  6. 6. Color maths in color-engine.js
  7. 7. Standards and Auric SD
  8. 8. Rendering the map
  9. 9. State, events and the URL
  10. 10. The windowed corpus browser
  11. 11. Accessibility behavior
  12. 12. Performance measures
  13. 13. The shared API engine
  14. 14. Testing
  15. 15. Extension points

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.htmlSEO 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.jsThe 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.jsPure 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.jsThe seeded five-million-pair corpus: configure(), pairStream(), pairForIndex(), enrichRecord(), getRecordByIndex(), recordId(), indexFromRecordId(), datasetInfo().
css/contrast-map.cssLayout and theme. Its --cm-* tokens point into the site's --aa2-* deck, so the page follows light and dark mode.
data/accessibility-db/accessibility.jsonThe corpus manifest, 2,969 bytes.
api/lib/handlers/corpus.jsThe 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.jsNode unit tests for the two modules.
tests/ui/contrast-map.spec.jsPlaywright 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-count and cm-corpus-seed, filled from the manifest; a [data-aa-credit-hint] slot where js/common/tool-credit-hint.js places its API line.
  • Ground bar: buttons with data-cm-ground="#RRGGBB" and aria-pressed; cm-ground-custom (input type="color"); cm-swap; target buttons with data-cm-target="t3|t45|t7"; cm-territory.
  • Map: cm-map (role="slider", tabindex="0"); the field layers cm-field and cm-field-dim; SVG polylines cm-line-t3, cm-line-t45 and cm-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) and cm-pin-rows (table body).
  • Index: cm-index-input, cm-index-go, cm-index-result; filter buttons with data-cm-browse-filter="pass|all"; cm-browse-shown, cm-browse-scanned, cm-browse-total; cm-browse (the spacer), cm-browse-win (the window) and cm-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

+
  1. init() runs on DOMContentLoaded, or at once if the document has already loaded, and returns if #cm-map is missing.
  2. bind() attaches pointer and keyboard handlers to the map, one delegated click handler on document, and input handlers for the color picker, the lookup box and the palette box.
  3. render() draws the field, contours, veil, picks and readout for the default state: ground #1D2A3E, target t45, hue 190, lightness 0.78.
  4. bindBrowse() calls browseReset() and adds scroll and resize listeners.
  5. 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
seedStarting state of the xorshift32 stream for batch 0.9001
batch_sizeRecords per batch; the stream reseeds at each batch.50000
batch_seed_strideAdded to the seed once per batch number.2654435761
countSize 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, strict and recommendation.
  • 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) returns acc_ plus the index in base 36; indexFromRecordId(id) accepts that form or bare digits and returns null otherwise.
  • 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 from maxChromaRgb() at L = 1 down to 0. renderField() writes each column as a <span> with a vertical linear gradient into both #cm-field and #cm-field-dim. The field does not depend on the ground.
  • Contours: buildContours(groundHex, groundRgb) returns { t3, t45, t7 }, each 72 boundary lightnesses or null, cached by ground hex. polyline() writes them into the SVG, which uses a 0–100 viewBox with preserveAspectRatio="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. null hues are skipped.
  • Veil: failClip(line, dark) returns a CSS polygon() covering the failing side of the current target's line. #cm-field-dim is a greyscale, darkened copy of the field clipped to it; a null hue 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 for null.
  • Readout: the caret color is chromaAt(light, hue); the panel shows its ratio, verdictFor(), rounded APCA, the minimum of cvdScores() 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__lift segment to the line for failures.
  • Picks: buildPicks() tries PICK_COUNT = 12 hues at (i + 0.5) × 30°, sets L to the boundary plus or minus PICK_MARGIN = 0.10, clamped to 0.03–0.97, and drops any pick still under the target. picksAsCss() formats the set as a :root block of --ground and --fg-NN with 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
groundCurrent ground hex; default #1D2A3E.
targett3, t45 or t7; default t45. TARGETS maps each to its ratio (3, 4.5, 7), label and note.
hue, lightCaret position in OKLCH hue and lightness; default 190 and 0.78.
pins, pinsDirtyPasted colors, upper-cased and de-duplicated, and a redraw flag.
picksThe current pick set.
field, contoursThe cached field and the contours for the current ground.
  • Pointer: pointerdown captures the pointer; pointermove while dragging maps the position to hue 0–360 and lightness 1–0 in pickFromEvent() and calls renderSoon(), which coalesces to one render() 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 call preventDefault() and render().
  • Clicks: one delegated handler on document covers data-cm-ground, data-cm-target (which also resets the browser when it is filtered), data-cm-pick, #cm-copy-css, #cm-index-go and #cm-swap. bindBrowse() adds a second for data-cm-browse-filter and data-cm-record.
  • Inputs: the color picker's input event sets the ground; the palette box is debounced by 200 ms into readPins(); Enter in the lookup box runs lookup().
  • 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 from pairStream(0) until browse.items holds want entries or SCAN_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 computed grid-template-columns and the row height from the first card plus the row gap. paintWindow() works out the visible rows with OVERSCAN = 2 rows each side, sets the spacer #cm-browse to the found rows plus RUNWAY = 4 so there is always more to scroll into, and moves #cm-browse-win with translateY.
  • Recycling: cards are <button class="cm__rec"> elements reused in place; a card's markup is rewritten only when its data-cm-record changes.
  • 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-map is role="slider" with aria-valuemin="0", aria-valuemax="360" and aria-valuenow set to the hue. aria-valuetext carries the whole reading, for example #00d2ca on #1D2A3E, 7.64 to 1, passes 4.5:1 for body text at the defaults. Keep aria-valuenow: a slider without it is announced with no value.
  • The reading panel (aside.cm__read) is aria-live="polite", and everything the map shows is also text there.
  • Ground, target and browse-filter buttons expose aria-pressed, kept in step by syncControls() and the filter handler. The target and filter sets are role="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-strong maps 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: none on the map lets a touch drag move the caret instead of scrolling the page, and prefers-reduced-motion removes transitions.

12. Performance measures

+
  • chromaAt() memoises maxChromaRgb() with lightness quantised to 1/512 and hue to 0.5°, and clears the cache past 60,000 entries.
  • contourCache keeps 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/mapThe contours and territory for a ground.
POST /v1/accessibility/picksThe 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/corpusThe browse list, with a cursor and a scan budget.
GET /v1/accessibility/corpus/statsThe measured contrast distribution of the corpus.
GET /v1/accessibility/corpus/:record_idThe 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-science runs tests/color-science in Node. color-engine.test.js checks 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, null for unreachable targets, APCA polarity, ten vision conditions, the Auric SD weights and hex parsing. corpus.test.js checks 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=chromium drives the page (npm run test:ui runs all of tests/ui). Without BASE_URL the config starts its own server through tests/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_91 is #4E1229 on #D86062 at 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 with data-cm-target, an SVG polyline with id cm-line-<id> and a legend entry. buildContours() computes one contour per entry, so each target adds 72 contour searches per ground. Revisit verdictFor() if the new ratio needs its own wording.
  • Resolution: COLS and STOPS set what is drawn, and the chromaAt() quantisation is matched to them. Contour cost grows with COLS.
  • Picks: PICK_COUNT, PICK_MARGIN and picksAsCss(). Keep the picks endpoint's defaults in step.
  • Scoring: color-engine.js is 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.js exports init, buildContours, territory, failClip and COLS.

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.