Browse documentation
On this page
Contributions
Cirth is developed in the open at
github.com/cirthcss/cirth.
Bug reports, accessibility findings, and focused pull requests are all
welcome. The full workflow lives in
.github/CONTRIBUTING.md;
this page is the practical summary.
Getting set up #
npm is the only supported package manager: one install path, one lockfile.
Node.js 24.18 (current LTS) is the version CI and the release workflows run
on; .nvmrc pins it for local use with nvm use.
git lfs install # once per machine — visual test baselines are Git LFS objects
git clone https://github.com/cirthcss/cirth.git
cd cirth
npm install
If you cloned before installing Git LFS, the
screenshot baselines under tests/__screenshots__/ are small pointer
files instead of images; git lfs pull fetches the real ones.
The commands you'll actually use:
npm run build # compile src/ to dist/ (format, lint, compile, minify)
npm run dev # rebuild on change
npm run lint # stylelint + CSS custom property check
npm run lint:fix
npm run docs:dev # run this docs site locally
Where things live #
src/contains SCSS source. It is repository infrastructure, not a public Sass API: the npm package ships compiled CSS fromdist/only, and customization stays CSS first through--cirth-custom properties.src/theme/contains design tokens: color scales, foundations, and the light/dark schemes. Most visual changes start here, not in components.src/presets/contains the maintained token override presets published alongside the default build. Its filenames are the source of truth used by the build, docs switcher, accessibility matrix, and visual suite; adding a preset automatically adds it to those checks.docs/contains this site (Eleventy), styled by Cirth's own build — and also doubles as the fixture the library's own QA runs against:tests/(visual regression) andcheck:a11yrender the built docs site to catch unintended rendering/accessibility changes from a source edit. Kept in this repository rather than a dedicated one on purpose, for now: the library is still under heavy, fast-moving revision, co-locating docs with source keeps a change and its matching docs update in the same commit checked by the same CI, and the QA coupling above means anyone doing serious library work needs the docs build regardless of where it lives. Whether to eventually split docs into their own repository — and do a full manual review pass over everything written so far — is something to evaluate later, not a settled plan; noted here so the current layout isn't mistaken for either the permanent shape of the project or a fixed roadmap item.scripts/contains local Node build and check scripts. Prefer extending these over adding tooling dependencies; a new package needs to provide a real build capability that would be risky to maintain locally.tests/contains the visual regression suite and its screenshot baselines (stored in Git LFS).
Package exports #
package.json declares an exports map with one entry per generated
build, so bundlers and Node's resolver can pick a build without knowing
the dist/ filename convention:
{
".": "./dist/cirth.min.css",
"./classless": "./dist/cirth.classless.min.css",
"./scoped": "./dist/cirth.scoped.min.css",
"./classless/scoped": "./dist/cirth.classless.scoped.min.css",
"./presets/plain": "./dist/presets/plain.min.css",
"./presets/playroom": "./dist/presets/playroom.min.css"
}
A ./dist/* wildcard keeps existing deep imports resolving. If you add a
build variant, add its export path and verify it resolves from a packed
tarball (npm pack) before opening the PR.
Quality gates #
The properties this site advertises — the size, the WCAG AA compliance, what each build variant does and doesn't contain — are not checked by review attention. Each one is enforced by an automated check that runs in CI on every push, and the same checks run locally:
npm run lint # tree noise + stylelint + custom property audit + browser target + doc links + CDN hashes
npm run build # compile src/ to dist/
npm run check:dist # structural invariants of every generated dist file
npm run check:size # ≤ 14 KiB gzipped per root bundle
npm run docs:build # build this site (input for the browser checks below)
npm run check:behavior # interaction, reflow, user styles, and input parity across three engines
npm run check:a11y # axe WCAG 2.0–2.2 A/AA audit of every docs page
npm run check:visual # screenshot diff across docs pages and maintained presets
npm run check:tooling # the dead-CSS audit measures a frozen copy of the build
check:tooling is the only one of these that checks a tool rather than the
library; it is seconds, and it runs locally rather than in CI because it
rewrites docs/dist on purpose. Two more tools run on demand — a rendering
fingerprint and a dead-CSS audit. See
On demand
below.
The browser-based checks need the Playwright browsers once:
npx playwright install chromium firefox webkit.
Interaction regressions: check:behavior #
Runs focused interaction tests against Chromium, Firefox, and WebKit. These
tests cover browser-managed states that static screenshots cannot exercise,
such as focus, blur, :user-valid, and :user-invalid; mouse, Enter, and
Space activation parity; and open dialog and popover states. For the default
theme and every preset discovered from src/presets/, the suite also checks
every docs page at 320 CSS px, with text at 200%, with WCAG text-spacing
overrides, and with forced-colors: active. Reflow assertions compare
documentElement.scrollWidth with its clientWidth, detect clipped text, and
check open top-layer surfaces independently. WebKit provides the closest
automated coverage available for Safari's rendering engine.
Dist invariants — check:dist #
Runs mechanical assertions over every file in dist/ right after the
build, so the contracts of each build variant can't erode silently:
- every build re-parses with Lightning CSS and is non-empty;
- classless builds emit no class selectors (the
.cirthwrapper is the single exception in the scoped variant); - scoped builds keep every rule inside the
.cirthsubtree — no selector can style markup that didn't opt in; - presets only set custom properties on theme roots, never rules.
CDN integrity — check:sri #
The <link> snippets on Get Started and in the README
pin a version and carry the sha384 hash of the file that version
serves. check:sri (part of npm run lint) keeps the two honest: every
snippet has to pin the version in package.json, carry a well-formed hash,
and set crossorigin="anonymous", without which the browser cannot verify
the response at all.
Version and hash are rewritten together by npm run sri at release time,
then verified against the bytes jsDelivr actually serves with
npm run check:sri -- --from-cdn after publishing. Editing either by hand
is how you ship a snippet that every browser refuses to load; the
RELEASING.md
has the full sequence.
Browser target — check:browserslist #
The Browserslist target names ten browser families but describes a single
engine floor: Opera and Samsung Internet are Chromium forks, Firefox for
Android is the same Gecko as the desktop build, and iOS Safari is WebKit
either way. Raising Chrome and forgetting Opera does not widen
support — it quietly lowers the floor back to whatever Chromium that Opera
release was built on, because Lightning CSS compiles for the oldest engine
in the list, and nothing else in the repository notices.
check:browserslist (part of npm run lint) asserts that invariant: every
Chromium family on the same version, the two forks on the releases built
from it, Firefox and Safari in step with their mobile counterparts, and the
legacy Android Browser absent — it is the same engine as Chrome for
Android and including it makes Lightning CSS expand every grouped selector
in the library. The Chromium-to-fork table lives in the script with a note
on how it was derived; when the floor moves past it, the check says so
instead of passing quietly.
Documentation lines #
The site is versioned at breaking changes, not at releases. A line covers
every version that documents the same API, which is why the switcher in the
header reads up to v0.10.0 / from v0.11.0 rather than listing patches.
Before 1.0 that boundary is a minor release; after it, a major.
Three things make it work:
docs/src/_data/versions.jslists the lines. Adding one is a hand edit — a deliberate decision, not something a script infers from tags.docs/versions/<dir>/holds the frozen sites. They are built once, at the release that ends them, and committed as-is. They are never rebuilt: an archived line should not have to keep compiling against a toolchain that has moved on.npm run docs:archive -- <tag> <dir>does the freezing, in a detached worktree so the working tree is untouched. It repoints the tag'spathPrefixat the archive's own subdirectory before building, since a tag predates the directory it ends up in.
At a breaking release, in order: archive the outgoing line
(npm run docs:archive -- v0.10.0 v0.10), move current: true onto the
new entry in versions.js, and write the migration on
Upgrading. The archived line is excluded from check:a11y
and the visual suite on purpose — auditing it would re-audit what the
toolchain thought a year ago, and any finding would be unfixable.
Where the site is published #
The root is always master, which is to say the version on npm. develop
is built beside it under /next/, with a banner on every page saying that
what you are reading has not shipped. Both come out of one Pages artifact,
built by the same workflow, so a push to either branch refreshes both and
neither goes stale while the other moves.
Accessibility — check:a11y #
Runs axe-core with the explicit
wcag2a, wcag2aa, wcag21a, wcag21aa, and wcag22aa tags against every
page of the built docs site. The complete matrix covers the default theme and
every preset discovered from src/presets/ in light, dark, and forced-colors
modes. Dialog and popover demos are audited once more while open. Since the
component demos live on these pages, this continuously re-verifies the
framework's own AA claim, not just the site around it.
The shell-free framework and component-state specimens are audited separately in Default, Plain, Playroom, and a custom blue primary. Light and dark additionally cover real hover, keyboard-focus, and pointer-active states; forced colors keeps the complete static specimen and the dedicated resilience checks below.
axe's contrast algorithm cannot model the system-color remapping performed by
forced-colors mode, so color-contrast remains enabled in light and dark and
is disabled only for that emulation. The behavior suite covers the forced-
colors rendering directly, including focus rings, loading indicators, borders,
open surfaces, and horizontal reflow.
The check fails on any violation not listed in
scripts/a11y-baseline.json. That baseline exists so an intentionally
accepted finding can be recorded explicitly — but it is empty, and the
goal is to keep it that way: fix violations rather than baseline them.
Visual regression — check:visual #
Playwright screenshots the content region of the default theme's selected
documentation pages in light and dark modes, at 1440 px and 390 px, and
compares each against a committed baseline in tests/__screenshots__/. The
shared documentation chrome has its own viewport-sized baseline, so changes to
the header, sidebar or outline remain covered without invalidating every page
capture. The set includes the
prose-heavy About, Customization, Get Started, Upgrading, and Contributions
pages: a large editorial rewrite must produce a reviewable visual diff too.
Every preset discovered from src/presets/ also renders a representative
matrix covering colors, semantic meter states, button variants, form validity,
and open modal and popover states. The default theme gets the same open-state
captures. Adding a preset therefore adds visual cases automatically; a missing
stylesheet or docs-switcher option fails before a screenshot is accepted. Any
unexplained pixel difference fails the check.
A single representative Chromium board also compares real rest, hover, focus, active, disabled, closed, and open pixels across the four shell-free themes in light and dark. Multi-engine state parity remains a behavior assertion, avoiding hundreds of duplicate image baselines without replacing real interaction.
Two things to know about the baselines:
- They are per-platform. System font rendering differs between
operating systems, so macOS runs compare against the
*-darwinsets and CI compares against the*-linuxsets. You never edit the Linux sets by hand — see below. - They live in Git LFS, so the repository history stays small while the images stay versioned.
When your change intentionally alters how something renders:
- regenerate your platform's baselines —
npm run check:visual:update; - commit them with the change, and say in the PR what changed visually and why.
You don't need to touch the Linux sets. On every push that could affect
rendering, the update-visual-baselines workflow regenerates them on a
CI runner and, if anything actually differs, commits them back to the
branch as github-actions[bot] — this self-heals both a first-time
bootstrap and a partial change (only the pages your PR touched drift).
Review that commit's images like any other diff.
That commit lands as a separate, later push, so check:visual in the
same CI run as your content change can fail against the not-yet-updated
Linux baseline — this is expected for a visual change, not a
regression. Re-run the check (or wait for the bot commit and push again)
once it lands. If check:visual fails and you didn't intend a visual
change, that's the check working — fix the regression instead of
updating baselines.
On demand: the rendering fingerprint and the dead-CSS audit #
Two tools that do not run in CI. They answer a question the gates above cannot: is this refactor really a no-op, and is this rule really dead?
node scripts/fingerprint-docs.js --out .cache/before.json
# make the change, then:
npm run docs:build
node scripts/fingerprint-docs.js --compare .cache/before.json
fingerprint-docs.js walks the built site and records one hash per
rendering: every page, at 1440, 1100, 900 and 390, in light and dark, twice
over — as the page loads, and again with every <details>, <dialog> and
popover opened. Both of those matter. A rule that only paints inside the
drawer or the search dialog is invisible to anything that looks at a page
that has just loaded; and the shell's tier ladder has seven boundaries, so
sampling only 1440 and 390 leaves the whole tablet band unmeasured. 800
renderings, about a minute, and --compare names the elements that moved
rather than only telling you something did.
Each element is hashed on two things: its box and its paint-bearing
computed style, and separately the tag, the text it owns and the attributes
that belong to the document rather than to its styling (href, id,
role, aria-*, …). So a copy change is a finding even when nothing
re-wraps — which is how it should have caught "Zero JavaScript" becoming
"No JavaScript runtime". It renders under prefers-reduced-motion: reduce,
because a measurement taken half way through a transition is noise, and it
says so rather than pretending otherwise.
node scripts/audit-dead-css.js # the whole shell stylesheet
node scripts/audit-dead-css.js --filter ".docs-toc" # one family of selectors
node scripts/audit-dead-css.js --explain-order # show what it visits first, and why
node scripts/audit-dead-css.js --json .cache/dead.json
node scripts/audit-dead-css.js --no-order --no-skip # both optimisations off, same verdicts
--no-order and --no-skip turn off the two things the survey buys — the
richest-first visit order, and skipping a rendering that provably cannot
see anything still undecided. They exist so those two can be measured
against a run that differs in nothing else, and so a verdict can be
reproduced without them.
audit-dead-css.js uses the same corpus to test declarations one at a
time: take it out of the live CSSOM, re-measure, put it back. It surveys
first — one pass that records which declarations each rendering can even
see — and then probes richest-rendering-first, so a declaration proved live
on the home page is never probed on the other forty-nine pages. Five
verdicts:
- live — removing it changed something, and the report names the first rendering that noticed.
- inert — its selector matched real elements, and removing it changed nothing anywhere in the corpus. A candidate for deletion; read it before you delete it.
- unmatched — its selector never matched an element on any page. Read this as a fact about the corpus too: it opens what a page keeps closed, but it never types, hovers or clicks, so markup a script builds in response to input — the search results Pagefind renders after a query — lands here and is very much alive.
- not observable — it sits under a media context the corpus does not
enter (
forced-colors,print,prefers-reduced-motion: no-preference), or behind a state it does not reach (:hover,:focus,::backdrop,::selection), or it does something a still photograph cannot show: anenv(safe-area-inset-*)that resolves to zero on a machine without a notch, an inset on aposition: stickybox in a corpus that never scrolls, a transition. Not a finding. These have not been shown to be dead; they have not been looked at. - unprobeable — the declaration could not be taken out of the rule at
all, because it is a longhand of a shorthand written with
var(), which the engine stores whole. The experiment never ran, so there is no verdict.
The last two categories are the reason to trust the first two. A report that cannot say "I did not measure this" will eventually persuade someone to delete a hover state.
The corpus is one engine and one theme: Chromium, default preset. Both
halves of that have already produced a false inert — a width beside a
flex-basis that Chromium ignores and Gecko and WebKit do not, and a
font-family pinning the shell's chrome to the system stack, which does
nothing until a preset makes the page face rounded. Both were caught by
check:behavior and check:visual, after the declarations had been
deleted.
So nothing is deleted on the sweep's word alone. The sweep writes its
inert list out, with the smallest set of renderings that can see each
one, and a second pass re-probes exactly those in the configurations the
sweep cannot enter:
node scripts/audit-dead-css.js --json .cache/dead-css.json
node scripts/verify-dead-css.js --report .cache/dead-css.json
verify-dead-css.js runs Firefox, WebKit and the playroom preset over
that plan — a few dozen renderings rather than 800, minutes rather than
hours — and turns each inert into one of:
- inert — nothing moved in any configuration. This is the verdict that makes a declaration safe to delete.
- engine-dependent — Chromium measured nothing, Firefox or WebKit did. Keep it, and name the engine in a comment beside it.
- preset-dependent — the default theme measured nothing,
playroomdid. - unverified — the configuration never entered the rule's media condition, never matched its selector, or could not take the declaration out of the rule at all. Looked for, not measured; it keeps the sweep's verdict and gains no confirmation.
- interacting — inert alone and not inert with the others. The pass
ends by removing everything it just confirmed at once, because that is
what a cleanup does and a one-at-a-time probe cannot see it: two
declarations can each be dead only because the other is alive.
.docs-header-search { width }and.docs-search-trigger { width }are exactly that pair, in Firefox and WebKit, and deleting both together is how the 320px regression shipped the first time.
It also samples one width the sweep does not: 320px, below the shell's own 22.5rem tier, which is where the original regression was seen and the only width that enters that tier at all. A fifth width costs a fifth of the sweep and almost nothing here. (It has not yet changed a verdict on its own — the pair above was caught at 1440. It is cheap insurance against the band nothing else samples.)
It is not a third browser suite. It reuses the audit's corpus, its in-page
measurement and its vocabulary, and it answers the audit's question — does
this declaration do anything? — in more places. The other two keep their
own: check:behavior is what a page does, check:visual is what it looks
like. Run them after acting on a report, not instead of reading it.
Both tools measure a copy of the built site. docs/dist is a build
output, and npm run docs:build replaces files in it — eleventy's
passthrough copy replaces styles/style.css rather than editing it, so
there is a window in which a page loads with no stylesheet at all. A sweep
that runs for the better part of an hour cannot ask everyone else not to
build, so it copies the tree once at startup (8 MB, well under a second),
serves the copy, and removes it when the run ends — including when the run
ends by throwing. npm run check:tooling is the proof: it rewrites
docs/dist underneath an open corpus and checks the corpus never sees it.
What they cannot copy is the source they are a claim about. A sweep ends
with a list of declarations to delete from docs/src/styles/style.css, and
that list only means anything for the file the sweep started with — so run
one worker at a time against a working tree. Two editors, terminals or
automated assistants sharing one checkout will eventually have one of them
rewrite a file the other is half an hour into validating, and the result
looks clean rather than wrong. Both tools hash the sources they depend on
at the start and refuse the run if either has moved by the end, naming the
file and saying whether it was edited or restored from a commit; a report
produced from one tree is likewise refused against another.
A whole-sheet run takes about forty minutes: it re-probes anything still
undecided on every one of the 800 renderings, which is exactly what makes
an "inert everywhere" verdict worth having, and an inert declaration is
precisely the one that survives to be probed everywhere. --filter narrows
it to one selector family in a couple of minutes and is the right tool for
everyday work; the full sweep is for a cleanup pass. Neither tool fails a
build: an inert declaration is something for a person to look at, and a
check that blocks a merge over one only teaches people to stop running it.
What makes a good contribution #
- Keep the surface small. New components need a strong case; new utility classes need a stronger one. If native HTML can express it, style the element instead.
- Don't regress the accessibility floor. Contrast ratios, focus
visibility, and the 44px control target size are verified properties of
the source; a PR that trades them away for aesthetics won't land. The
target size is a floor, not a fixed height — controls may grow past it,
and
navopts down to a 40px band that remains above WCAG 2.5.8's 24px minimum — so the property to preserve is that nothing drops below what it is entitled to. - Stay on the spacing scale. Spacing values are
--cirth-space-*tokens (0.25rem steps to 1.5, 0.5 steps to 3, then whole rems). If a value isn't on the scale, that's a design smell worth flagging. - Match the CSS first philosophy. Runtime customization through custom properties beats switches decided at compile time; those switches beat new build variants.
License #
Contributions are accepted under the project's Apache License 2.0 (see NOTICE.md for attribution). Documentation contributions fall under the same terms.