npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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

  1. Value collisions — two variables that must differ hold byte-identical colours. A real one: --app-border equalled --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.
  2. Dead variables — declared, referenced nowhere.
  3. Ramp collapse across themes — --text-muted and --text-faint must 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.
  4. 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.
  5. 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 inline declarations count as declaring, so an alias that exists is never reported.
  6. Cycle references — a var() chain that returns to a name already on it. Per CSS custom-property semantics every property in the loop — and every var() consuming a member — is invalid at computed-value time, yet the shape audits green: dead-token is silent (the names ARE referenced, by each other) and unresolved-reference is silent (they ARE declared). The finding is one per loop per author: a loop the base declarations (or the @theme inline alias 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.
  7. 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 — :root beside [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.
  8. Unresolved imports — a relative @import whose 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) printed No 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.
  9. 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 direct var() (.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 themeguard

Usage

One command, one option — point it at a CSS file.

npx themeguard path/to/application.css

Several stylesheets take one invocation — the positionals repeat:

npx themeguard web/app.css admin/panel.css

Each 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/*.css

The 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 --json run 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 is missing or unreadable), 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.css importing b.css importing a.css terminates — 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 bare selector:line whose line number would point into whichever file the reader had open. The same rule governs every rule's Declared 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.json sits 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's file field) 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-ignore directives 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. collision takes 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() and relativeLuminance() refuse one and say so; call over(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.ts reproduces its documented ΔL* steps (+6.07, −11.49) to the hundredth. See tests/fixtures/README.md.

Licence

MIT.