The check
What .kampa/check.mjs grades, what makes it fail, and what a failure looks
like in Kampa and in your CI. Everything here describes what the check does
today. Where something is not built yet, the page says "not yet".
One check, two places
The same rules run in two places:
- In Kampa, before every push. Kampa grades the tokens and components it is about to write, as the project has them saved. With Block push on check failure on, a failure stops the push before anything reaches your repository. Some failures stop it either way (below).
- In your CI, from
.kampa/check.mjs. Kampa commits the file on every push. It grades the files as they are committed, so it also catches changes made outside Kampa: hand edits, other tools, agents.
The pairing rules, colour and alias resolution, the value rules (E006,
E007, E008, E009), the name rule (E010), the
duplicate-key scan and component resolution are copied into check.mjs from
Kampa's own source when Kampa is built, and a test fails Kampa's build if a
copy ever differs. The rest of check.mjs is written to match, and tests run
the same token files through both and fail on any disagreement.
Running it
From the repository root:
node .kampa/check.mjs tokens/
- Plain Node.js. It imports only Node's
fsandpathmodules, so there is nothing to install. - With no argument it reads
tokens/. Pass a folder to read another, or pass one or more.jsonfiles. - From a folder it reads every
.jsonfile directly in it, exceptmanifest.json. Each file is one mode, named after the file:dark.jsonis thedarkmode. Subfolders are not read as modes. Component documents are read fromcomponents/inside that folder. --level AAAgrades at AAA. The default is AA.- Exit code
0is a pass,1is a fail. - Standard output is one JSON report. Standard error is the readable summary,
ending in
kampa check: PASS ✓orkampa check: FAIL ✗. - Under GitHub Actions it also writes an error annotation for each problem, on the file and line of the token it names, when it can find the line. GitHub shows ten per step; the tenth says how many more there are.
It reads files and prints. It makes no network request and writes no file.
What it grades
Contrast, pair by pair
For each mode, the check measures the WCAG 2.2 contrast ratio of every intended pair: a foreground colour and a background it is meant to sit on. It does not measure every foreground against every background. It works out which pairs are intended from the tokens' names and roles.
A token is a colour when its $type is color, its own or its group's. A
colour token's role is, first match wins:
- its declared
rolein$extensions.kampa.meta:foreground,backgroundorborder; - a path segment
foreground,fg,background,bgorborder; - a guess: a path containing
text,iconoroutlinereads as foreground, one containingsurface,overlayorfillas background. Failing that, agroupofsurfacereads as background, andbrandorsemanticas foreground.
A declared role of none skips step 3 but not step 2. Border tokens are never
paired. Only foreground against background is measured.
A foreground and a background are an intended pair when:
on-pairs. The foreground is namedon-<x>and the background is<x>in the same group, for examplecolor.brand.on-primaryoncolor.brand.primary. Anon-<x>foreground inside aforegroundgroup also pairs with a background whose path contains every--separated part of<x>and whose name is one of those parts,defaultoremphasis.- General pairs. A foreground under
foregroundorneutral(or declared foreground, with nogroup) and a background underbackgroundorsurface(or declared background, with nogroup). A token namedinvertedonly pairs with another namedinverted. - Sibling pairs. Tokens named
fgandbgin the same group.
A background named <name>-hover or <name>-active pairs like <name>, so a
foreground is measured against every interaction state of its surface.
When a declared role overrides what a token's name says (a *-fg token
declared as a background), the pairs its name would have made are not
measured. The summary lists them under Dropped by a declared role, so they
are never missing without a word.
Thresholds
| Level | Passes | Fails |
|---|---|---|
| AA | 4.5:1 or more | under 4.5:1 |
| AAA | 7:1 or more | under 7:1 |
A pair that reaches only the large-text threshold (3:1 at AA, 4.5:1 at AAA) fails. The report prints it as "passes large only", but the check does not know where the pair is used, so it grades every pair as normal text.
Pairs that cannot be measured
An intended pair the check cannot measure fails. It is never skipped. This is
lint code E004, and it names the reason for each side:
| A side that | Reason, as it prints |
|---|---|
| is an alias that does not resolve | unresolvable reference chain |
| resolves to something that is not a colour | non-color target or value |
is translucent: a colour object with an alpha below 1, or a 4- or 8-digit hex with transparency such as #00000080, read exactly the same way |
translucent color (alpha 0.502) can't be contrast-checked without a compositing context. Exempt this pair, with a reason, to acknowledge it |
is in a colour space other than srgb |
color space "oklch" is not measured; Kampa measures srgb |
has a "none" component |
srgb component "none" can't be measured |
is an srgb object that isn't three numbers from 0 to 1, or an alpha outside 0 to 1 |
malformed srgb color value: …, saying which |
| is a hex string of the wrong length | malformed hex color value (#12345) |
A colour is measured when it is a colour object with "colorSpace": "srgb",
three components from 0 to 1 and no alpha below 1, or a hex string (3, 4,
6 or 8 digits, fully opaque), the shape Kampa wrote before and still reads.
The check measures the components, each rounded to a byte: exactly the colour
tokens.css renders. It never reads a colour object's hex to measure it.
A colour object with no $type anywhere above it is still a colour.
A transparent colour has no single contrast ratio without knowing what is behind it. Kampa does not guess. You can exempt such a pair, with a reason (see below).
Lint
Every token in every mode is also linted. Each problem fails the check. The codes, as they print:
| Code | Means | Example message |
|---|---|---|
E001 |
An alias whose target does not exist, a loop of aliases, or a chain of more than 8 references. | Orphaned alias "{color.nope}" |
E002 |
A $type DTCG does not define, or a node with both a $value and child tokens. |
Unknown $type "colour" |
E003 |
A name segment that does not start with a letter or digit, or contains anything but letters, digits, - and _. |
Invalid segment "Bad Name" |
E004 |
An intended pair that cannot be measured, with the reason for each side. | Intended pair can't be evaluated: "color.foreground.x": unresolvable reference chain |
E005 |
A colour excluded from pairing with no reason. | "color.foreground.x" is excluded from pairing with no reason. |
E006 |
A dimension in a unit other than px or rem: a dimension token, or a typography token's fontSize or letterSpacing. |
"spacing.em" in light uses "em", which DTCG does not define as a dimension unit. Use px or rem. |
E007 |
An srgb colour object whose hex is not its components rounded to bytes. A reviewer reads the hex; the components are what ships. A colour with no hex is fine. |
"color.x" in light: hex "#ff0000" does not match its components [1, 0, 0.5], which are #ff0080. Edit components, or delete hex. A hex that is not six digits, such as #fff, says so: hex "#fff" must be six digits, #rrggbb. |
E008 |
A dimension Kampa can't read: a dimension token, or a typography token's fontSize or letterSpacing, that is neither an alias, a { value, unit } object nor a number with a unit. |
"spacing.md" in light "medium" is not a dimension Kampa can read. Write it as { "value": 16, "unit": "px" }, in px or rem. |
E009 |
A value Kampa can't write into tokens.css. A string that would break out of its CSS declaration: it contains ;, {, }, \, !, <, a comment mark, a line break, or an unclosed quote or bracket. A value of the wrong shape for its type, such as a number written as a string. A type Kampa can't write yet: a duration, transition or gradient object, or a colour in a space other than srgb. The message names the path, the mode, the member inside a composite, the reason and the fix. |
"motion.fast" in light {"value":200,"unit":"ms"} is a duration object, and Kampa can't write this type into tokens.css yet. Replace it in the code panel, or remove the token. |
E010 |
Two token paths that become one CSS custom property name, such as Brand and brand, bodyLg and body-lg, or c.primary-light and c-primary.light. In tokens.css the last would silently replace the first, and tokens.ts would not compile. Also a path with no letters or digits, which has no name. |
"Brand" and "brand" in light both become --brand in tokens.css, so one would silently replace the other. Rename one. |
E001 to E010 are the only lint codes. They are the same in Kampa and in
check.mjs.
Names follow Style Dictionary's own rule: the path's segments are joined, split
where the case changes, lowercased, and every run of other characters becomes
one -. Digits stay with their letters, so h1 and h-1 are two names.
check.mjs carries its own copy of that rule, from
change-case under the MIT
License, with the licence in the file.
Shapes from before
A file Kampa pushed before it wrote the DTCG 2025.10 value shapes, or one
edited back by hand,
may hold a colour as a hex string, "#ffffff", or a dimension or a
typography fontSize as a string, "16px". The check reads them as it always
has: measured, and not a lint error. Kampa's next push writes them as objects
and lists the rewrite under Rewritten by Kampa.
Components
When components/ holds component documents, the check resolves every
combination the resolver declares (every variant, state and theme, not only
the ones shown in Kampa) and measures the component's own foreground and
background slots in each. A failing slot pair, or one that cannot be measured,
fails the check, named by component, variant, state and theme.
Contrast exemptions do not apply to components. A component pair has to pass.
A component document that breaks the component format stops the run with the
format's own error code in brackets, such as [slot-alias-unresolved]. The
check grades every component first, so one run names every broken component,
each under its own file.
schemas/component/<version>/SEMANTICS.md, pushed beside the documents, lists
every one. No components/ folder means nothing to grade, not a failure.
Format 0.3 components
A component that declares format: "0.3" lists the pairs it wants graded,
each with a kind, and keeps its states as groups in the component file. The
check grades it per tile: for every combination of the resolver's axes
(theme, variant and any other), the resting state and each state group the
component declares, such as hover or hover.checked. In each tile both sides
of a pair take the value that state paints.
| Kind | Needs | |
|---|---|---|
text |
4.5:1 at AA, 7:1 at AAA | text on its own background |
non-text |
3:1 at both levels | a border or a focus ring that shows the control is there |
decorative |
nothing | listed as excluded, "declared decorative", never graded |
- A tile in the
disabledstate lists its pairs as excluded, "inactive component". WCAG exempts inactive controls. - A pair against
pageis graded against your project's general surfaces:color.backgroundandcolor.surface, eachdefault,subtleandmuted, plus any colour token you declare with rolebackgroundand no group. The worst result counts. A component that sits on a brand, inverted or card surface is not graded against it, and the summary says "graded against the project's general surfaces only". A project with none of these tokens cannot be graded againstpage, and the pair fails with "declare a background role". - A focus ring is graded only in tiles where
focus-visibleis active, and a checkbox check or radio dot only wherecheckedis.
Kampa's pull request counts graded pairs only. Its Components table reads "120 of 120 graded pairs pass, 144 not graded": the excluded pairs (declared decorative, or in a disabled tile) are named apart and never counted as not passing.
The check also prints Component warnings, not blocking: a colour slot that points at a palette primitive and so will not change between modes, a state group that can never apply, and two slots one build would name alike. A warning never fails the run.
Format 0.4 components
A component that declares format: "0.4" can have parts (a Card's title,
an Accordion's items), and a part can be another component (a Dialog's
Buttons, a Field's Input). Everything above holds, per element, and:
- Tiles cover every element. A state can be held by one element and style
another: an open item styles its trigger, an invalid control its message. The
tiles are the resting state and every condition the component declares,
such as
trigger.hover,item.openorselect.checked. pageis the surface under the element. For a part it is the nearest element above it with a solid fill, else your general surfaces. A fill that is fully transparent (alpha exactly 0, such ascolor.transparent) paints nothing, so a pair on it is graded on what shows through. A colour with any other transparency cannot be measured and fails. Text or a ring that is itself transparent fails too: an invisible indicator is not a passing one.- Inherited text is graded. A part that holds text and sets no colour of its own shows its parent's, and the check grades that colour on the part's fill or the surface under it. The summary lists these as inherited text.
- Nested components are graded where they sit. A Button inside a Dialog is graded once on its own, on your general surfaces, and again in the Dialog, on the Dialog's surface: every state of the Button against every surface the part can sit on.
- Disabled is the element's own. A disabled part excludes only the pairs it paints and those inside it. The rest of the component is still graded.
- Focus rings next to other parts. A ring sits outside its element and
reaches outline-offset plus outline-width past it. The check walks up from
the focusable part: at each level, the boxes beside it (the other parts of
its parent, and the next instance when it repeats) are neighbours unless the
parent's
gapis at least the ring's reach. A neighbour's fills are graded against the ring at 3:1, and a ring that does not stand out from one fails with[ring-overlap]. A parent whosepadding-inlineandpadding-blockare both at least the ring's reach keeps the ring inside it, and the walk stops there. Padding on a table row or row group holds nothing. - A dialog's backdrop is never graded against. Its pairs are declared decorative, and the dialog's own fill must be solid in every state.
- One rule keeps every pair honest. A condition that changes one side of a
pair, or a fill the pair sits on, changes every side some condition changes.
When the component format cannot spell the cell that would take (a state on a
part can never style the component's root), the document fails with
[pair-closure-unspellable], naming the condition and the fill. Focus rings are graded instead in every combination of the state that draws the ring with every state that changes a fill under it, so neither repeats the other. - Nesting resolves across files. The check reads every component in
components/before grading any, so a Field finds the Input ininput.json. A missing child, a child on format 0.1 or 0.2, a context the child does not have, or a loop fails the run, named on the parent. - Base styles. When
base.cssis written beside a 0.4 component, its colours are graded on your general surfaces: the text colour and the link colour as text, the focus ring as non-text, thehrcolour as decorative.
A component you place inside a part by hand (a Button in an Alert's body),
and base.css styles inside a part, are graded on your general surfaces only.
The summary says "and inside components, against the surface each part sits
on" when a project has a 0.4 component.
Files it cannot read
A file that cannot be read, is not valid JSON, or has the same key twice in one object stops the run before any grading, naming the file and the line. The duplicate-key check runs because a JSON parser silently keeps only the last value.
The level
The level is a project setting in Kampa: Settings, then Deploy, then
WCAG level, AA or AAA. Kampa grades at that level before every push, and
manifest.json records it as wcagLevel.
check.mjs grades at AA unless it is run with --level AAA. The workflow
file below is generated for your project: for an AAA project its run line
carries --level AAA.
Exemptions and exclusions
Both take a pair out of grading. Both are recorded in the mode files, and both are listed in every report, so a reviewer sees each one.
An exemption covers one pair at one level. You set it in Kampa, in the contrast matrix, with exempt this pair and an optional reason. It is stored at the root of every mode file:
"$extensions": {
"kampa": {
"meta": {
"contrastExemptions": {
"color.foreground.disabled|color.background.default|AA": {
"reason": "Inactive UI component text, exempt per WCAG 2.2 SC 1.4.3"
}
}
}
}
}
The key is the foreground path, the background path and the level. An AA exemption does not cover the same pair at AAA. It covers every mode. An exempted pair is still measured. When it fails, it does not block: the report lists it under Exempted, not blocking with its ratio and reason, the pull request lists it under Exempted pairings, and the commit message names it.
An exclusion takes one colour out of pairing altogether, in every pair it
would be part of. You set it in Kampa with Exclude from matrix. It is
stored on the token as excludeFromPairing: true, with the reason in
excludeFromPairingReason. The reason is required: an exclusion without one is
E005 and fails. The pairs it removes are listed under Excluded from the
matrix, not blocking.
The workflow file
check.mjs runs in CI only if your repository has
.github/workflows/kampa-check.yml. Kampa cannot add it: it does not have
GitHub's Workflows permission and does not ask for it. After each push the
pull request description says whether the file is there, current or out of
date, with a link that opens GitHub with the file filled in.
The file Kampa offers:
- is named Kampa token check, with one job and one status check, WCAG 2.2 contrast + token lint;
- runs on every pull request, so it can be a required check;
- passes in seconds, saying there was nothing to check, when the pull request
changes nothing under your token folder,
.kampa/or.alloy/; - otherwise runs the check on that folder on Node 20:
.kampa/check.mjswhen the repository has it, and.alloy/check.mjsuntil Kampa has moved its files. The run line isnode "$([ -f .kampa/check.mjs ] && echo .kampa || echo .alloy)/check.mjs" tokens/.
The file is generated for your project. Two things in it change with the project, and nothing else does:
- The token folder, in two lines: the change test and the run line. A
project that keeps its tokens in
design/tokensgetsdesign/tokens/in both. A folder name with a space or a quote in it is single-quoted. - The level, on the run line: an AAA project's line ends in
--level AAA. At AA there is no flag.
The pull request description names the folder and level the file checks. Kampa compares your file with the one it would offer your project. If you wrote those two parts by hand, with or without a trailing slash, with the level before or after the folder, it still counts as current. Any other change makes it out of date. If you change the project's token path or level in Kampa, your file is out of date until you replace it, and the link in the pull request opens GitHub with the new one filled in.
An older workflow is named alloy-check.yml and runs .alloy/check.mjs only.
Kampa reads it the same way and marks it out of date for that reason, at the
top of the pull request description, with two links in this order: add
kampa-check.yml, then delete alloy-check.yml. Both pull requests are green,
because the new file runs whichever checker the repository has and reports the
same status check. Kampa's files stay in .alloy/ while a workflow needs
.alloy/check.mjs (names it and not .kampa/check.mjs); the first push after
that moves them to .kampa/. See .kampa/README.md, section 3.
To make the check required, see .kampa/README.md, section 4. The check must
have run once in the repository before GitHub lets you require it. On Rail 1,
the Kampa GitHub App needs to be on the ruleset's bypass list, because Kampa
merges before CI reports.
When the check fails
In Kampa
With Block push on check failure on (Settings, then Deploy; on by default), a failing check stops the push. No branch, no commit, no pull request. A banner above the editor counts the failing pairs, and Show details names every one with its mode, its ratio and the ratio it needs. Show in matrix opens the failing token pair, Show in components opens the failing component. Fix it and use Retry push. Kampa checks everything again first. Once everything the banner listed is fixed, it says Ready to push again instead, with Push again.
With it off, a failing push goes ahead. The pull request says FAIL in its check row and lists every failure. On Rail 1, Kampa still merges it, unless your branch rules require the CI check and the app is not on the bypass list.
Some failures stop the push on both rails whatever the setting says, because
Kampa can't write tokens.css and tokens.ts from them: E001, E002,
E008, E009 and E010. The window behind Show details names each one once, with the modes it was found in, and a link that selects the token in the table. A mode whose name Kampa
can't use as a file name, a CSS selector and a tokens.ts key stops the push
too, with a message naming the mode. A mode name Kampa can write starts with
a letter or a digit, then has only letters, digits, spaces, dots, underscores
and hyphens, up to 64 characters, with no trailing space or dot and no ...
In CI
The WCAG 2.2 contrast + token lint check fails. Its log lists what blocks first, one line per problem, with the modes it holds in:
Token contrast: 1 problem
color.foreground.default on color.background.default: 3.54:1 in light. Fails 1.4.3 AA normal text (< 4.5:1, passes large only)
Summary, WCAG 2.2 AA
Token contrast: 1 intended pair check in 1 mode (light), 1 fail
Lint: 2 tokens, no issues
Not graded: 0 combinations not intended pairings
kampa check: FAIL ✗ 1 problem, listed above
Then what is held out of grading (exempted, excluded, dropped by a declared role), then a summary of every count, and the verdict on the last line. Each blocking problem is also annotated on its file in the pull request's diff.
Fix it in Kampa and push again, rather than editing the files by hand. Kampa writes the token and component files from the project.
What the check never does
- Skips an intended pair it cannot measure. It fails instead.
- Applies a contrast exemption to a component.
- Changes a file, or makes a network request.
- Reads
manifest.jsonas a mode, or a subfolder other thancomponents/.