themeguard
v0.1.18
Published
Audits a project's CSS colour variables: value collisions between roles, dead tokens, ramps that collapse between themes, and families a theme tunes everywhere except the members it silently inherits.
Maintainers
Readme
themeguard
ESLint for your colour variables. Reads the CSS source, not the rendered page, and audits how a project's colour tokens are organised — not whether text passes contrast.
Status: 0.1.1 — one command, and a library.
themeguard <file.css>audits a stylesheet from a terminal, and the same nine rules are importable as functions. The package ships compiled output (dist/); the calibration fixture and the tests stay in this repository and out of the tarball.
The nine questions
- Value collisions — two variables that must differ hold byte-identical colours.
A real one:
--app-borderequalled--app-surface-raised, so a panel's 1px border was literally invisible. It survived three and a half months and was worked around with a third token rather than fixed. - Dead variables — declared, referenced nowhere.
- Ramp collapse across themes —
--text-mutedand--text-faintmust stay apart by eye. If they converge in one theme, the text hierarchy is gone. Measured in CIE L* (ΔL* ≥ 4), not in contrast ratio: two adjacent surfaces a visibly distinct step apart still sit around 1.2:1. - Family consistency across themes — when a theme re-declares part of a token family but not all of it, the member it skips silently keeps the base theme's value. A dark-tuned green flowing into a light theme beside the light theme's own re-tuned siblings is exactly the defect that accumulates rather than appears; the finding cites the overridden siblings as the evidence, and the per-theme coverage inventory behind it is printed as facts — which tokens each theme re-declares, and which it inherits, colour and non-colour.
- Unresolved references — a
var()naming a custom property that no scope declares. The property resolves to nothing: it falls back to unset or inherited, or to whatever fallback was written — so a typo'd name, a fallback pointing at nothing, a declaration built on a name nobody wrote and an alias chain into a typo all pass silently today. The finding is one per name, lists every use site, and stays factual: no did-you-mean guessing, and@theme inlinedeclarations count as declaring, so an alias that exists is never reported. - Cycle references — a
var()chain that returns to a name already on it. Per CSS custom-property semantics every property in the loop — and everyvar()consuming a member — is invalid at computed-value time, yet the shape audits green:dead-tokenis silent (the names ARE referenced, by each other) andunresolved-referenceis silent (they ARE declared). The finding is one per loop per author: a loop the base declarations (or the@theme inlinealias namespace) author is reported once for the stylesheet, and a loop a theme's own declarations close is reported for that theme — a theme that merely inherits a root-authored loop never re-reports it. - Duplicate declarations — one name declared twice in one scope with differing values. The
cascade keeps the last declaration and silently discards the rest, so the file the author reads
says one thing and the browser paints another; until this rule the shape audited green, and the
report's own coverage inventory even deduped it into one token. The finding is one per source
block, names the shadowed and the winning site with both values, and is deduped across the two
scope halves a selector-list prelude (
:root, [data-theme="dark"] { … }) yields. A cross-scope override —:rootbeside[data-theme="dark"]— is the theme system working and never fires, and a same-value repeat resolves to the identical cascade outcome, so it never fires either. - Unresolved imports — a relative
@importwhose target is missing or unreadable. The audit unit is the file's import closure, so a failed edge breaks the audited document's own composition — and until this rule the failure was swallowed silently, so@import "./toens.css";(a one-character path typo) printedNo findings.and exited 0 while every declaration inside the file it names stayed invisible to the audit. Every other tool in the chain notices the breakage: postcss-import, vite and webpack error on a missing relative file at build time, and a browser fails the request. The finding is one per failed edge, names the specifier exactly as written, the resolution outcome (missing/unreadable) and the statement's own position — cited by file when the statement lives in an imported sheet. Bare package specifiers, absolute paths and URLs are still not findings (package resolution is a build step, not a source read), and an import cycle is still not one either — a browser dedupes an already-applied stylesheet the same way, and the closure audits. - Theme-partial tokens — a custom property declared only inside ONE theme's block is absent from
every other theme's view, and a declaration chain in such a view that reaches for it ends unresolved:
the property falls back to unset or inherited. The tool's earlier silence had a precise shape —
resolution is per-theme, but rule 5 asks a stylesheet-wide question, so a name declared anywhere was
a lookup hit everywhere. The finding is one per missing name (never per consumer or per theme),
carries
theme: null, and names all four halves: the token, the themes that declare it (with their declaration sites), the views it never reaches, and the chains that break. Not detected: a component rule's directvar()(.code-block { background: var(--app-code-bg) }, no declaration chain) mints no unresolved token, so it is out of scope by design — a consumer written inside the declaring theme's own selector ([data-theme="dark"] .card { … }) is correct CSS, and telling the two apart needs selector-prefix reasoning the parser deliberately does not do. Reporting correct CSS as a defect is worse than this silence, so the chain face — which the resolver itself vouches for — ships, and the consumption face is recorded as a residual rather than guessed at.
Defects like these accumulate rather than appear. Over eight months of one growing project's CSS the
palette went 23 → 73 tokens and collisions went 0 → 7. (That figure predates the rule; running
collision over the same stylesheet today reports 11 across its two themes — see the table below for
how many candidates that is filtered down from.)
What themeguard does not do
Contrast of text against its background in a rendered page. axe-core does that, does it better, and has ten years of edge cases in it. Use axe for the rendered page and themeguard for the stylesheet; they answer different questions.
Scope
Any CSS that declares custom properties — :root, [data-theme="…"], and Tailwind v4's
@theme inline alike. No framework required and no browser required. SCSS $variables are compiled
away before themeguard sees anything: point it at the compiled CSS and it works, but the link back to
the source names is lost.
An OS-preference palette is a theme of its own, not a shadow over the base. @media
(prefers-color-scheme: dark) { :root { … } } — how GitHub Primer, shadcn and plain-vanilla dark mode
all write it — resolves as a dark theme beside root, with the same declared/inherited split as any
attribute theme. That is what keeps a defect that exists only in the light palette reachable, and an
attribute theme's inherited values resolving to the real base rather than to the dark block's
overrides: a media-conditioned block does not cascade over :root, both are live, selected by the OS
setting. The theme is named for the feature value; a stylesheet that also declares
[data-theme="dark"] is stating the dark palette twice, by attribute and by OS preference, and the
two land in one dark table — last declaration in source order winning, exactly as two
[data-theme="dark"] blocks already did. The nested writing works too:
:root { @media (prefers-color-scheme: dark) { … } }.
Deliberately not taken: a media prelude whose condition is more than the one feature — a comma list
(an OR of conditions), a second feature (… and (min-width: …)), a non-screen type (print) — and
@supports/@layer conditions all keep today's reading, folded into the base. A palette selected by
part of a compound condition is not a palette of its own; modelling that honestly needs
condition-aware tables, which this package does not pretend to have.
One file set per audit, one declared composition per file. Reference resolution does not cross files a
human merely listed together: themeguard a.css b.css audits two independent sheets, because nothing in
the source says how they relate, and a rule that guessed at a relationship would report defects in a
document nobody wrote. What does cross is what the source declares: an @import edge is composition with
CSS-defined semantics, and along it the audit reads one document — see
Stylesheets composed with @import.
Install
npm install --save-dev themeguardUsage
One command, one option — point it at a CSS file.
npx themeguard path/to/application.cssSeveral stylesheets take one invocation — the positionals repeat:
npx themeguard web/app.css admin/panel.cssEach file is audited independently — each through its own import closure — and each prints its own
report under its own themeguard — <path> header: the config governing a stylesheet — the nearest
themeguard.config.json beside it or above it — governs that stylesheet alone, and one POSITIONAL's
tokens are invisible to the next. Files named together on a command line state
no relationship, and none is invented; the only thing that crosses is what a file's own text declares,
along the @import edges
Stylesheets composed with @import covers. The invocation fails
fast on the first file that cannot be audited (unreadable, an unhonourable config beside it or above it, a
malformed directive in it): the reports already printed stand, and the error names the file that
stopped the run. Otherwise the per-file outcomes aggregate into one exit code for the
invocation, precedence 2 > 1 > 0 — any file errored, 2; else any file reported unsuppressed
findings, 1; else 0. A pipeline or pre-commit hook gets one invocation and one verdict over a
whole project's stylesheets, instead of N runs and a hand-ORed aggregate that one mistyped path
silently poisons.
--json — the report as data
--json is the one option. It replaces the prose on stdout with NDJSON — one compact JSON object
per stylesheet, written as that file completes, in argument order:
npx themeguard --json src/*.cssThe object is the same report the library returns, verbatim, with the audited path in front of it:
findings (each with its rule, theme, tokens, message, evidence and — where the rule has a
position to give — its sites, each naming a token, a line and the imported file it was spliced from),
countsByRule, suppressed, unmatchedSuppressions, skipped and coverage. Nothing is renamed,
summarized or dropped. The prose renderer is a lossy projection of that object — sites become clause
text, evidence becomes sentence fragments, scopes become bracket suffixes — so a caller that wanted
which file, which token, which line had to scrape sentences shaped for people.
It composes with jq the way a per-line stream should:
themeguard --json src/*.css | jq -c 'select(.findings | length > 0)'
themeguard --json src/*.css | jq -r '.path as $p | .findings[] | "\($p): [\(.rule)] \(.message)"'Three properties a pipeline caller may rely on:
- The exit codes are unchanged. The flag adds a channel; it does not move the verdict. Branch on the number exactly as today, and parse stdout for the detail behind it.
- stdout is never mixed. It is either pure prose or pure NDJSON — usage errors, unreadable files, an
unhonourable config and a malformed directive stay on stderr as prose in both modes — so every line
of a
--jsonrun parses without a mode check. - Fail-fast keeps the lines already written. The first file that cannot be audited ends the invocation
with
2, and the JSON lines for the files before it are already on stdout — which is why this is NDJSON rather than one array, whose closing bracket a fail-fast run never reaches.
The flag is accepted in any position among the paths, and repeating it changes nothing. There is no short
alias. Everything that is not --json is a positional, exactly as before — an unknown --flag is read as
a path and fails as one — and themeguard --json with no stylesheet is the usage error it has always
been, never a silent success.
Run over this repository's own calibration fixture (a real 97 KB Tailwind stylesheet with two themes,
vendored at tests/fixtures/application.tailwind.css), it prints:
themeguard — tests/fixtures/application.tailwind.css
collision (11)
[collision] --app-border and --app-surface-raised both resolve to #1E293B in theme "root". They are separate roles, and theme "winter" declares them apart — so this theme is repainting one with the other. Declared at lines 41 and 33.
[collision] --app-cta and --app-success both resolve to #22C55E in theme "root". They are separate roles, and theme "winter" declares them apart — so this theme is repainting one with the other. Declared at lines 29 and 54.
… 9 more, across both themes
dead-token (2)
[dead-token] --topbar-height is declared at :root:402 and no var() in this stylesheet references it.
[dead-token] --transition-slow is declared at :root:407 and no var() in this stylesheet references it.
scale-collapse (2)
[scale-collapse] --app-accent-ink-hover is ΔL* 3.90 from --app-accent-ink in theme "root" — under the 4 needed for a visible step, so the hover state is not distinguishable from the resting one. Declared at lines 376 and 375.
[scale-collapse] --app-accent-ink-hover is ΔL* 3.45 from --app-accent-ink in theme "winter" — under the 4 needed for a visible step, so the hover state is not distinguishable from the resting one. Declared at lines 551 and 550.
family-consistency (7)
[family-consistency] --app-cta-solid-hover is inherited from :root in theme "winter" (resolving to #4ADE80) while the same theme declares --app-cta, --app-cta-hover — the theme tunes this family, so the member it does not re-declare silently keeps the base value. Declared at line 312.
[family-consistency] --app-success is inherited from :root in theme "winter" (resolving to #22C55E) while the same theme declares --app-success-border, --app-success-on-surface, --app-success-soft, --app-success-surface, --app-success-toast-surface — the theme tunes this family, so the member it does not re-declare silently keeps the base value. Declared at line 54.
… 5 more
unresolved-reference (0)
cycle-reference (0)
duplicate-declaration (0)
unresolved-import (0)
theme-partial-token (0)
skipped (0)
nothing skipped — every pair rule 3 derived was measurable.
suppressed (0)
nothing suppressed — every finding above is one the report stands behind.
unmatched (0)
nothing unmatched — every recorded judgement still covers a finding this report carries.
coverage (2 themes, 73 base tokens)
root: declares all 73 base tokens, inherits 0.
winter: declares 51 of 73 base tokens, inherits 22 (8 color / 14 non-color).
[inherited] --app-success (color)
[inherited] --app-focus-ring-width (non-color)
… 20 more — every base token each theme inherits, named
22 findings: 11 collision, 2 dead-token, 2 scale-collapse, 7 family-consistency, 0 unresolved-reference, 0 cycle-reference, 0 duplicate-declaration, 0 unresolved-import, 0 theme-partial-token.Four things in that output are deliberate and worth reading.
All nine rule headings print even at zero. A rule that reports nothing and a rule that did not run look identical if the heading is omitted, and "nothing here" reads as a pass.
skipped is a section, not a silence. A pair rule 3 could not measure — a translucent member has no
lightness until it is composited, and themeguard never invents a backdrop — is unmeasured, which is not
the same claim as clean. Those pairs are counted and named, and they do not change the exit code:
they are not findings.
coverage is facts, not findings. Inheriting a token is normal — focus geometry, control sizing and
transitions are theme-independent on purpose — so the inventory never moves the exit code. It is printed
under the same discipline as skipped (counted, named, headline even at zero) because a theme that
silently receives a value is a fact a reader needs before the family-consistency findings above it make
sense; the findings are the judgement, the inventory is what they are judged against.
suppressed is what you, not the tool, decided. The section headline prints even at zero, under the
same discipline as skipped and coverage — an empty suppressed section is the visible proof that
nothing was set aside. When a themeguard.config.json governs the stylesheet — beside it, or the nearest
one in a directory above (see the next section) — its findings move here with their reasons quoted: out of
the counts and the exit code by declaration, never by silence.
unmatched is what your judgements have to say about this run. The complement of suppressed:
declared entries — config or directive — that no finding matched, named with their reasons so a
judgement that has outlived its defect announces that instead of going silent. It prints even at zero,
and like skipped and coverage it never moves the exit code. See
When a judgement matches nothing.
Stylesheets composed with @import
A stylesheet composed from others — @import "./tokens.css"; at the top of an application sheet — is ONE
document as far as CSS is concerned, and since 0.1.10 the audit reads it as one. themeguard follows the
@import edges the entry file's text declares and audits the whole import closure: a token declared in
an imported file is declared for the composing sheet's rules, and a token the composing sheet reaches with a
var() counts as referenced in the imported file. Without this, each file's audit lied about the other —
the composer reported every imported token as an unresolved reference, and the imported file reported its
own tokens as dead the composer was using.
What is followed, and what is deliberately not:
- Followed: relative specifiers —
./tokens.css,../shared/props.css— written either way CSS writes them,@import "./x.css";or@import url("./x.css");, with or without a media suffix. Each target is resolved against the directory of the file whose text carries the statement, transitively, and spliced at the statement's position: imported rules first, the author's own after, so an importing file's re-declaration of an imported name wins exactly as the cascade says — and the resolver reads that merged document order, never per-file line numbers, which restart at 1 in every file and would silently hand the cascade to whichever block happened to sit deeper in its own file. - Skipped, silently, and never a finding: bare package specifiers (
@import "tailwindcss"— package resolution is a build step, not a source read), absolute paths and URLs. Their silence is deliberate and unchanged: the specifier may well resolve at build time, and reporting every bundler-handled path as a defect would drown the report in noise. - Reported: a relative specifier whose target is missing or unreadable. That failure used to be
swallowed silently too — so a sheet whose own composition could not load audited green, a
one-character path typo shipped
No findings.at exit 0 — while every other tool in the chain reports it. Since 0.1.14 the loader records each failed relative edge (with the specifier as written, the statement's line, the containing file, and whether the target ismissingorunreadable), and rule 8,unresolved-import, turns the record into findings that move the exit code. An edge broken two hops in is named with its own from-file. This is the boundary of the audit's file-graph interest: edges the source declares are judged; files nothing imports are never checked. - Cycle-proof:
a.cssimportingb.cssimportinga.cssterminates — a visited set splices each file once, however many edges point at it. - Cited by file: a finding whose site lives in an imported file names the file —
declared at tokens.css:3— instead of a bareselector:linewhose line number would point into whichever file the reader had open. The same rule governs every rule'sDeclared at …clause: one finding's positions can now sit in two files, so a clause containing any imported position cites each site independently (Declared at tokens.css:3 and line 7.) rather than pooling bare line numbers that may not even share a file. Sites in the entry file render exactly as they always have. - Governed by the root's config:
themeguard.config.jsonsits beside the stylesheet you point the command at — or at the nearest directory above it holding one — and an unscoped entry matches by rule, token and theme — it has no file axis at all — so one judgement written beside the root governs findings living anywhere in the closure. A file-scoped entry (0.1.11'sfilefield) governs the same span by naming it: the stylesheet it names is the ENTRY stylesheet, the closure's root, so whatever the invocation audited, the entry covers.themeguard-ignoredirectives are the opposite: a judgement written at one site in one file, and they are read from the entry file's text only — so a directive matches only entry-file sites, by line, and never a finding spliced in from an imported file whose line merely coincides.
@import is the one cross-file relationship that is declared in source, which is why it is the one that is
followed: CSS defines the semantics, and the tool invents nothing. The genuinely unknown relationships — a
list of files passed on a command line, a bundler's virtual sheet, a sibling file with no import edge — stay
out of scope: themeguard audits one closure per invocation, and rule 5's honesty about what it cannot see
starts at that boundary.
Suppressing deliberate findings — themeguard.config.json
The audit is calibrated to report what it measures, not what it approves of — so a real stylesheet that
documents its own deliberate choices (a hover intentionally a hair off its resting colour, a warning fill
that legitimately equals its border) reports them every run. To agree with the audit with exceptions,
put a themeguard.config.json next to the stylesheet — or in the nearest directory above it that
holds one:
{
"suppress": [
{ "rule": "scale-collapse", "token": "--app-accent-ink-hover", "reason": "deliberately subtle hover" }
]
}Scoped entries — a deliberate collision pair in one theme, and nothing else:
{
"suppress": [
{
"rule": "collision",
"tokens": ["--app-cta", "--app-success"],
"theme": "root",
"reason": "the cta is deliberately the success colour, in the base palette only"
}
]
}A file-scoped entry — a judgement recorded against one stylesheet of a directory that shares one config (the config may sit in the stylesheet's own directory, or any directory above it — see Discovery below):
{
"suppress": [
{
"rule": "collision",
"tokens": ["--accent", "--primary"],
"file": "tokens.css",
"reason": "brand tokens are identical by design"
}
]
}Each entry names the rule that reports the finding, a token dimension, an optional scope, and the
reason — which the report quotes back. Matching is against the finding's structured fields (rule,
theme, tokens), never against message prose — plus the stylesheet the entry names, when it names
one, compared against the file being audited. A suppressed finding leaves the per-rule counts and the
exit code, and moves to its own suppressed (N) section — counted, named, reason quoted, never silently
dropped, under the same discipline as skipped.
Scope an entry, or it reaches everywhere. An entry names its token dimension either as the scalar
token — any one token the finding carries — or as the tokens array — a set the finding must carry in
FULL, which is how a deliberate collision pair is recorded as one entry rather than a stroke across
every finding that happens to carry one of its members. An entry may also carry theme, narrowing it to
findings measured in that one theme — so a deliberate equality in your base palette never silences the
same question in a prefers-color-scheme or [data-theme=…] theme, where the equality may be a real
defect. A theme-scoped entry never matches a finding that is not about a theme at all (a dead token is
measured stylesheet-wide). And it may carry file — the stylesheet the judgement was recorded against,
relative to the config's own directory — narrowing it to findings reported for that ONE stylesheet. This
is what makes a shared-config directory honest: when styles/ keeps one themeguard.config.json beside
tokens.css and buttons.css, an entry with "file": "tokens.css" suppresses the brand collision it
was written about and says nothing about buttons.css's findings — the header's own sentence, "a
suppression is worth exactly the file it was recorded against", true by declaration rather than by
accident of where the config sits. The config's directory is where the spelling is anchored, wherever
discovery found it: with the ledger at styles/ governing styles/components/ too, "file":
"components/card.css" names card.css relative to styles/, and fires on the component file the
judgement was recorded against. That home is also the field's boundary: a config governs its own
directory and below, so a scope naming a file OUTSIDE that subtree can never be honoured — no stylesheet
any run of this config audits sits beyond its reach — and the unmatched section says so rather than
offering retirement advice about a file that cannot exist. file must be RELATIVE — an absolute path
would make the same config mean different things from different checkouts, so it exits 2 naming the
entry, as would an empty or
non-string one. Omitted keys are the unscoped reading: token alone, no theme, no file — exactly
what one-line configs have always meant. A scoped entry says so in the report, where its finding is
printed:
[suppressed] [collision] --accent and --success both resolve to #ff0000 in theme "root". … — "cta deliberately equals success" [theme: root, tokens: --accent, --success]rule and reason and one of token/tokens are required, and a config the package cannot honour — an
unknown rule id, a missing token dimension, token AND tokens together (two spellings of one
dimension), an empty tokens array (which would match every finding), a malformed theme, unreadable
JSON — exits 2 naming the offending entry: a config that silently did nothing — or silently did too
much — would leave you believing a finding was marked deliberate when it was reported after all. A
malformed file — empty, non-string, or ABSOLUTE — is the same refusal, for the same reason: the field
must mean the same stylesheet from every checkout this config travels through.
Discovery is nearest-ancestor, deliberately. The config is found by walking UP from the directory of
the stylesheet you point the command at — that directory first, then each parent, bounded at the
filesystem root — and the FIRST themeguard.config.json on that walk governs. Nearest wins, the
eslint/tsconfig/.editorconfig prior, and never the process working directory — so the same command
means the same thing no matter where it is invoked from. The walk is what lets one ledger govern a
SUBTREE: a config at styles/ reaches styles/components/ too, so the standard component-library layout
(theme tokens at the root, components one directory down) is one config, not a copy per directory — and a
config beside the stylesheet is still the walk's first hop, so every layout that put it there keeps
byte-identical behaviour, shadowing anything an ancestor might carry. No config anywhere up the tree
means no suppression at all: no existing line of the report changes, the exit codes are unchanged, and
the only difference from a run without the feature is the counted suppressed section appended at zero —
with its unmatched complement beside it, also at zero.
Marking it at the site — /* themeguard-ignore … */
The config records a judgement next to the stylesheet; a directive records it in the stylesheet, on the line it is about — which makes it site-precise by construction:
:root {
/* themeguard-ignore collision --accent --success -- vendor brand: accent is deliberately the success green */
--accent: #16a34a;
}
--app-cta: #22c55e; /* themeguard-ignore collision --app-cta --app-success -- the cta is the success colour, judged here */The grammar is one comment: the keyword, then the rule id (one of the nine), then optional ---prefixed
token names — the same meaning as a config entry's tokens set, the finding must carry every name listed
— then a bare -- separator and a required reason, which the report quotes back. A directive that names
no token is legitimate: there the site is the judgement, and every finding of that rule living at the
line is covered.
A directive covers a finding only while the finding still lives at its site — trailing on the judged
declaration's own line, or standalone on the line directly above it, the two placements
eslint-disable-next-line and stylelint-disable-line established. Refactor the code away from the
annotation and the directive orphans: nothing matches, the finding prints again and moves the exit code.
The miss is self-announcing — exactly like a config entry that matches nothing but whose finding still
exists — and deliberately not a silence. A defect FIXED rather than moved leaves no finding to announce
anything: there the orphaned judgement is named in the report's counted unmatched (N) section instead,
still without becoming an error. That is the property the config cannot have: its entries match a
finding's identity, so a judgement recorded about one line keeps suppressing after the code moves to
another. Use the config for project-level judgements; use a directive when the judgement belongs to the
file and should travel with it into vendored, regenerated or forked copies. The two work together — both
merge into the same suppressions section, where a directive's line carries its [file:line] source
clause:
[suppressed] [collision] --accent and --success both resolve to #16a34a in theme "root". … — "vendor brand: accent is deliberately the success green" [tokens: --accent, --success] [vendor.css:4]A directive the package cannot honour — an unknown rule id, no rule id at all, a missing or empty reason,
a word in the head that is neither a rule id nor a -- token name — exits 2 naming the comment's line,
under the config's own never-silently-ignored discipline. In fact the values a directive may say are
validated by the config's own parser, so the two mechanisms cannot drift apart on what a suppression may
name. The keyword is recognized only in real comments: the same text inside a CSS string is prose.
When a judgement matches nothing — unmatched (N)
Suppression is a standing ledger of exceptions — entries accumulate, reasons carry sign-off dates, and
nothing ever told you when one had outlived the defect it was written about. An expired entry was
invisible to every surface: nothing matched it, so nothing printed, and a config carrying dead
judgements was byte-indistinguishable from no config at all. The report therefore carries a counted
unmatched (N) section — the complement of suppressed, in declaration order — naming every declared
entry, config or directive, that no finding matched:
unmatched (2)
declared suppressions no finding matched. Either the defect was fixed and the judgement can be retired, or the entry never aimed at a finding that exists — the report cannot tell which.
[unmatched] [collision] — "vendor brand, signed off 2026-01-15" [tokens: --page-bg, --page-ink] [app.css:3]
[unmatched] [scale-collapse] — "reviewed 2025-11-20, kept for the print theme" [token: --page-ink-hover]Each line names the entry's rule, its declared scope ([token: …] / [theme: …] / [tokens: …]), its
[file:line] source where it is a directive, and its reason quoted — the same vocabulary a suppressed
line uses, because an unmatched entry is an entry like any other, only without a finding behind it. A
suppressed line needs no [token: …] segment — the finding's own message already names the tokens the
judgement covered — but an unmatched line has no finding to point at, so the token dimension is printed
there instead; without it, two entries differing only in their token would be indistinguishable.
One unmatched entry the prose can vouch for. The dichotomy above — expired, or mis-aimed — has a
third reading since file scopes exist: an entry carrying a [file: …] clause matched nothing here
because it was recorded against ANOTHER stylesheet, which this config governs too. When the section
holds such an entry, a line beside the prose says exactly that, and names the file the clause names —
the judgement is neither retireable on this report's word nor unaccounted for: the file it aims at is
the one whose report states its fate. One boundary the config itself cannot cross, and the section
states it too: a config governs its own directory and below, so a scope naming a file OUTSIDE that
subtree can never be honoured by any run — for such an entry the report says exactly that, and that
report is the fate-statement: re-aim the entry inside the config's directory, or retire it.
The section prints even at zero. An empty section is the proof that every recorded judgement is still doing work, and a section that vanished at zero would reproduce exactly the silence this exists to remove:
unmatched (0)
nothing unmatched — every recorded judgement still covers a finding this report carries.It never moves the exit code. A stale entry is hygiene, not a defect in the stylesheet — the same
posture skipped and coverage take. The report cannot tell the two causes apart, and its prose says
so rather than pretending to: either the defect was fixed and the entry should be retired, or the entry
never aimed at a finding that exists. What it will not do is stay silent, and what it will never do is
fail your pipeline over a judgement you wrote — the exit stays exactly the question it has always been,
were there unsuppressed findings.
Exit codes
| Code | Meaning |
|---|---|
| 0 | The audit ran and reported nothing. |
| 1 | The audit ran and reported findings. |
| 2 | The audit did not run — bad usage, or the file could not be read. |
1 and 2 are kept apart on purpose: in a pipeline or a pre-commit hook the number is all a caller has
to branch on, and a real finding must never be confusable with a typo in the path — nor with a clean
stylesheet. The number stays exactly that; --json adds the DATA beside
it, on stdout, without moving the verdict — a caller reads the code for the decision and the NDJSON for
the detail behind it. Diagnostics stay on stderr as prose in both modes, so stdout is never mixed.
Over one invocation naming several stylesheets, these codes aggregate per invocation with the
precedence 2 > 1 > 0: if any file errored, the invocation exits 2 — fail-fast, with the reports the
files before it already printed left standing and the error naming the file that stopped the run; else
if any file reported unsuppressed findings, it exits 1; else 0. Each file is still audited on its own
terms — the config governing it, beside it or above it, is its ledger and its report is its own — the
aggregation is the invocation's
one verdict over all of them.
Suppression moves a finding between those codes by declaration: what a themeguard.config.json
entry — or a themeguard-ignore directive in the stylesheet itself — marks deliberate leaves 1's
population for 0's, and is still printed and counted in the report's suppressed section — the exit
is computed over unsuppressed findings only. Code 2 also covers a config that exists but cannot be
honoured, or a directive that cannot be parsed: the offending entry — or the comment's line — is named
on stderr, never silently skipped.
The unmatched (N) section is likewise outside the exit: a declared entry that matched nothing is a
stale or mis-aimed judgement, not a defect in the stylesheet, so an expired judgement never turns a
green pipeline red.
As a library
import { readFileSync } from "node:fs";
import { resolveCss, audit } from "themeguard";
const report = audit(resolveCss(readFileSync("application.css", "utf8")));
for (const finding of report.findings) {
console.log(`[${finding.rule}] ${finding.message}`);
}
console.log(report.countsByRule); // { collision: 11, "dead-token": 2, "scale-collapse": 2, "family-consistency": 7, "unresolved-reference": 0, "cycle-reference": 0, "duplicate-declaration": 0, "theme-partial-token": 0 }This is the same object themeguard --json prints, one line per file — so
a CLI consumer and a library consumer read one shape, and moving between them costs nothing.
resolveCss audits the TEXT you hand it — no base directory is known, so @import statements are
collected but not followed, and the report describes that one file. A caller with a file on disk — the
CLI's own case — loads the import closure instead, and the same audit sees the whole composed document:
import { loadStylesheet, resolveStylesheet, audit } from "themeguard";
const report = audit(resolveStylesheet(loadStylesheet("application.css")));Types ship with the package. Every finding carries the evidence behind it, so a verdict can be checked
rather than taken — and six of the nine rules also carry sites, the declaration each measured token
resolved from ({ name, line }), which is the same position their message ends with. It cites the
cascade winner: --app-border is declared twice in the fixture, at :root's line 41 and winter's
line 438, and the root finding cites 41 while the winter one cites 438. dead-token,
unresolved-reference and duplicate-declaration carry no sites — they name their positions in
their own messages (:root:402; every use site of the dangling name; the shadowed and winning
declaration), positions the merged theme tables cannot supply.
A library caller with the same need as the CLI — findings it has itself judged
deliberate — passes them as the optional second argument, and reads report.suppressed under the same
counted-not-silent discipline:
import { resolveCss, audit } from "themeguard";
const report = audit(resolveCss(css), {
suppressions: [{ rule: "scale-collapse", token: "--app-accent-ink-hover", reason: "deliberately subtle hover" }],
});
for (const { finding, reason } of report.suppressed) {
console.log(`[suppressed] [${finding.rule}] ${finding.message} — "${reason}"`);
}The complement is on the report too: report.unmatchedSuppressions carries the declared entries that
matched nothing — in declaration order, entries whole — under the same counted-not-silent discipline
the CLI prints as its unmatched (N) section.
Omit the second argument and the report is exactly the nine-rule audit it has always been.
Development
npm install
npm test # vitest
npm run typecheck
npm run build # tsc -p tsconfig.build.json → dist/The build has its own config on purpose. tsconfig.json is the checking config — noEmit: true, and
it includes tests/ — so passing --outDir to it emits nothing at all, silently. tsconfig.build.json
sets noEmit: false, rootDir: "src" (without it the output nests under dist/src/ and the manifest's
bin path is a lie) and includes src only.
The library lives in src/, in two stages that are deliberately kept apart — the lower one produces
facts and passes no judgement, the upper one judges those facts and nothing else:
| Module | What it answers |
|---|---|
| src/parse.ts | Which blocks declare custom properties, in which of the four shapes — :root, [data-theme=…], @theme inline, and a prefers-color-scheme :root block as its own theme — at which line, and every var() use, from every declaration rather than only the custom-property ones. |
| src/resolve.ts | What each property resolves to per theme, following var() chains. Theme absence, translucency, unresolved references and cycles are each represented explicitly — none of them is an error and none is guessed at. |
| src/color.ts | Colour parsing (hex 3/4/6/8, rgb()/rgba(), hsl()/hsla(), alpha throughout), WCAG relative luminance, CIE L*, contrast ratio, source-over compositing. |
| src/audit.ts | audit(resolved) — the nine rules in one pass, returning findings tagged collision, dead-token, scale-collapse, family-consistency, unresolved-reference, cycle-reference, duplicate-declaration, unresolved-import or theme-partial-token, plus the per-theme coverage inventory. Passing suppressions moves caller-declared findings out of findings and the counts into a suppressed leg. |
| src/config.ts | themeguard.config.json — optional, discovered at or above the stylesheet (nearest ancestor wins; a config beside the stylesheet is the first hop). Parses and validates the suppress entries (strictly: an unhonourable config is an error naming the entry, never a silent skip) into the structured declarations audit() filters a finished report by. |
| src/rules/ | One module per question. Each docstring carries its judgement heuristics and, more usefully, what it deliberately does not report. rules/coverage.ts also carries the coverage inventory itself — the facts rule 4 is measured over, printed by the CLI as an informational section and never an exit code. |
| src/cli.ts | The command. I/O and presentation over audit() — no rule, no heuristic and no judgement of its own. Two renderers over the same report: the default prose, and --json's NDJSON projection, which serializes the report verbatim for a pipeline caller. |
import { resolveCss, audit } from "themeguard";
for (const finding of audit(resolveCss(css)).findings) {
console.log(`[${finding.rule}] ${finding.message}`);
}Every rule is mostly a filter, and that is the actual work. The raw data holds far more candidates than there are defects, so a rule that reports its input is a rule that reports noise. Measured against the calibration fixture:
| Rule | Raw candidates | Reported | What the filtering removes |
|---|---|---|---|
| collision | 41 value groups (dark) | 11 across both themes | A token beside its own @theme inline alias; two names another theme re-declares equal in its own right; two members of one family (--app-warning / --app-warning-border is deliberate, and the fixture says so). |
| dead-token | 66 unreferenced names | 2 | The 64 @theme inline aliases, whose consumers are the utility classes Tailwind generates from them and so are unreachable to a source read by construction. |
| scale-collapse | — | 2 | Pairing is derived from declared interaction states (X / X-hover), not by sorting a family by lightness: that alternative returns 25 findings, including all four twins the fixture documents as deliberate. |
| family-consistency | 22 inherited base tokens (winter) | 7 | A finding needs the theme's OWN declarations as evidence: at least one sibling sharing the token's declared family head must be overridden. That drops the other 15 — the 14 wholly-inherited tokens (focus-ring geometry, control heights, topbar height, sidebar widths, radii, transitions, font — a theme inheriting a family whole is a legitimate answer, so no finding and no invented intent) and --app-solid-label, whose family head --app-solid is never declared, so its prefix neighbours belong to a different family and there is nothing to cite. |
| unresolved-reference | 214 var() uses naming 72 distinct names | 0 | Nothing to filter on this fixture — all 72 distinct used names are declared somewhere, and the @theme inline declarations count as declaring, so the alias namespace is a lookup hit, not a judged population. The rule's four populations (a typo'd use, a dangling fallback, a broken declaration chain, a dangling alias chain) simply do not occur here; the tests pin them on hostile sheets, and pin this 0 as a preservation check. One known false positive is named rather than hidden: an @property registration declares no scope (its block carries no -- declarations), so a name registered there and declared nowhere is reported — with an initial-value it resolves at runtime and that finding is a false positive; the standing remedy is a scope declaration or a suppression entry. |
| cycle-reference | — | 0 | Nothing to filter on this fixture — no var() chain in it returns to a name already on the chain, so there are no candidates at all. The tests pin the rule's populations on hostile sheets (a root loop, a self-loop, a theme-authored loop, the override-heals and inherited-dedupe negatives) and pin this 0 as a preservation check. |
| duplicate-declaration | 125 in-scope declarations of the 189 parsed, none repeated in one scope | 0 | Nothing to filter on this fixture — no name is declared twice within one scope, so there are no candidates at all. The tests pin the rule's populations on hostile sheets (a referenced duplicate, a theme-scope duplicate, the selector-list dedupe, the cross-scope-override and same-value-repeat negatives) and pin this 0 as a preservation check. |
| theme-partial-token | — | 0 | Nothing to filter on this fixture — no var() chain in it fails to resolve in any theme's view, so the rule's population (unresolved-kind tokens whose missing reference IS declared somewhere) is empty at the source. The tests pin the rule's populations on hostile sheets — the flagship one-theme declaration, theme→theme, the @theme inline alias chain, the prefers-color-scheme conditioned root — alongside the control partitioned to rule 5, the three negative boundaries in both directions, the recorded direct-consumption residual, and this 0 as a preservation check. |
Three properties are worth stating because they are what the tests defend:
- Silence is never read as intent. A rule that treats "no evidence of a defect" as "evidence of no
defect" fails as a clean report, which reads as a pass.
collisiontakes a pair to be a deliberate identity only when another theme declares it equal in its own right — not when the other themes merely say nothing. The earlier "equal in every theme" form was vacuously true on a single-theme stylesheet and reported nothing at all, including on this README's own opening example; the fixture, which has two themes that both override that pair, is structurally incapable of catching it, so the case is pinned by hand-written stylesheets instead. - A translucent colour is never composited against an invented backdrop.
lstar()andrelativeLuminance()refuse one and say so; callover(color, backdrop)naming the surface it is actually painted on. A colour with alpha has no single value without one — measuring it against an assumed black once produced a 1.82:1 reading where the truth was 9.25:1. - A rule reports what it measured. Every finding carries the numbers behind it — the shared value, the declaration sites, the ΔL* and both endpoints — so a verdict can be checked rather than taken. A pair rule 3 cannot measure (a translucent member has no lightness until it is composited) is reported as skipped, because silence reads exactly like a pass.
- The maths is calibrated against an independent implementation, and against a real stylesheet that
records its own measurements in comments:
tests/math.test.tsreproduces its documented ΔL* steps (+6.07,−11.49) to the hundredth. Seetests/fixtures/README.md.
Licence
MIT.
