Quality
What we test for each component, why we test it, and how the test runs. The numbers on the Components page are derived from these methods — this page is the long-form reference.
Tests passing
The percentage of recorded unit + functional tests that currently pass for a component. Unit tests run in Vitest against the component's pure logic; functional tests run in Playwright against the rendered DOM.
What is being checked
The pass-rate across two test layers, summed over all recorded tests for the component. A score below 100% means at least one recorded test is currently failing.
Why it matters
A failing test is the most direct signal of a regression. The two layers are intentionally orthogonal: unit tests catch logic bugs in isolation, functional tests catch contract drift between the component and the DOM/ARIA surface real users interact with.
How it is tested
Unit tests live at tests/unit/<Name>.spec.ts and run under Vitest. They mount the component with @vue/test-utils and assert against computed properties, emitted events, slot rendering, and prop validation — without a real browser.
Functional tests live at tests/e2e/components/<kebab>.spec.ts and run under Playwright in a real Chromium instance. They drive the rendered component at /isolate/<kebab> and assert on ARIA attributes, keyboard interaction, role correctness, click handlers, and visual contract (e.g. focus-visible ring, thumb position on a switch).
How to fix a gap
- Open the Health modal for the component — the per-layer table lists which spec failed and lets you inspect the spec source inline.
- Run the layer in isolation: `npx vitest run tests/unit/<Name>.spec.ts` or `npx playwright test tests/e2e/components/<kebab>.spec.ts --headed`.
- Re-record the result via `npm run test:component -- <Name>` once the fix lands.
Test coverage breadth
A presence-only check: does the component have both a unit spec and a functional spec? It does not measure how thorough those specs are — just that both layers exist.
What is being checked
Whether the component has at least one test at each of two orthogonal testing dimensions: unit (Vitest spec covering pure logic) and functional (Playwright spec covering rendered behaviour). 100% means both layers exist with at least one test each. 50% means one is missing.
Why it matters
A component with only unit tests can pass everything yet still break in a real DOM (ARIA misuse, keyboard regressions, focus traps). A component with only functional tests catches behaviour but misses fast, hermetic checks on logic edge cases. Both layers measure genuinely different things; a complete component should have both.
Accessibility is intentionally NOT counted here — it lives in the axe matrix coverage signal, which is a stronger and more specific measure than any "is there an a11y test?" presence check.
How it is tested
The build inspects the existence of two files per component:
- `tests/unit/<Name>.spec.ts` — unit layer
- `tests/e2e/components/<kebab>.spec.ts` — functional layer
Either file with at least one passing or failing test counts as "present". The pass/fail of those tests feeds the Tests passing signal, not breadth.
axe matrix coverage
The most rigorous single signal in the score. axe-core (the industry-standard accessibility engine, ~100 rules mapped to WCAG 2.1 A/AA) runs against /isolate-matrix/<kebab>, a page that renders every variant × state × theme permutation in one DOM. A pass means the component's entire surface is free of automatable a11y issues across every shipped theme.
What is being checked
The fraction of shipped themes (currently Light, Dark, HC Solar Light, HC Solar Dark) that pass an axe-core scan over the full variant × state matrix rendered on /isolate-matrix/<kebab>. axe inspects computed styles + the accessibility tree and reports only proven violations — zero false positives.
Each component declares its matrix in <Name>.matrix.ts: variant axes (e.g. severity, size, shape), forced states (hover, focus-visible, checked, disabled), and a base render. The isolate-matrix page expands the cartesian product and renders every cell in every theme simultaneously, so one axe scan covers the entire component contract.
Why it matters
A single passing scan is much stronger evidence than any other automated check we run. It says: every combination of variant + state, in every theme, has valid markup, valid ARIA, sufficient contrast, accessible names, and structural correctness. That is why it carries the heaviest weight in the score (35%).
What axe checks
- Visual — colour contrast (text/icon/border vs. background) against each theme's actual rendered tokens
- Semantics — valid roles, ARIA attributes, state reflection (`aria-checked`, `aria-disabled`, `aria-busy`)
- Naming — every interactive element has an accessible name (label, aria-label, aria-labelledby)
- Structure — no duplicate IDs, no hidden focusables, valid form-label association, heading order, landmark structure
- Native correctness — `role="switch"` on a checkbox-input, `aria-checked` matches state, etc.
How it is tested
The Playwright spec for each component contains one line:
test('<kebab> — matrix passes axe', async ({ page }) => {
await page.goto('/isolate-matrix/<kebab>')
await page.waitForSelector(matrix.rootSelector)
const blocking = await runAxe(page, '.isolate-matrix')
expect(blocking, JSON.stringify(blocking, null, 2)).toEqual([])
})The /isolate-matrix/[component] page is data-driven — it reads <Name>.matrix.ts and renders all permutations in a grid with one column per theme. axe scans the whole grid in a single call, so a violation in any cell fails the test and the failure message attributes it to the exact instance via data-variant + data-state attributes on the wrapping <article>.
How to fix a gap
- Run `npm run test:component -- <Name>` to regenerate the per-component scan output.
- Open the failure in the Playwright trace viewer — axe's message includes the exact rule id (e.g. `color-contrast`) and the failing nodes.
- For contrast failures: adjust the theme tokens for that component (`src/components/<Name>/<Name>.tokens.theme-*.json`), not the component itself.
- For ARIA / structural failures: fix in the .vue template; axe gives the precise rule and selector.
Non-text contrast
WCAG 1.4.11 requires 3:1 contrast for UI element surfaces (switch tracks, slider rails, button fills, etc.) — and axe-core does NOT enforce this. The design system closes that gap with a custom DOM walk that runs over the same matrix page.
What is being checked
WCAG 1.4.11 Non-text Contrast: visual information required to identify user interface components and their states must contrast at least 3:1 against adjacent colors. This covers the visible body of switches, sliders, checkboxes, radio buttons, progress bars, scrollbars — anywhere a control is perceived as a shape rather than as text.
Why a custom scan
axe-core only enforces 1.4.3 / 1.4.6 (text contrast). There is no axe rule for 1.4.11 — UI element contrast is left to manual review in the official guidance. For a design system that ships across multiple themes with auto-generated tokens, that gap is unacceptable: a slip in one theme can make a control invisible (which is exactly how this signal was born — a transparent toggle-switch track on white canvas passed every axe scan).
How it is tested
Each component has a second Playwright test alongside the axe scan:
test('<kebab> — matrix passes non-text contrast', async ({ page }) => {
await page.goto('/isolate-matrix/<kebab>')
await page.waitForSelector(matrix.rootSelector)
const violations = await runNonTextContrast(page, '.isolate-matrix')
expect(violations, JSON.stringify(violations, null, 2)).toEqual([])
})runNonTextContrast (tests/e2e/components/_helpers.ts) walks the matrix DOM in one page.evaluate, finds elements whose class name matches track / thumb / rail / fill, reads their computed background-color, walks up to the nearest opaque ancestor, computes the WCAG luminance ratio (compositing semi-transparent foregrounds over the background), and reports anything under 3:1.
Disabled states are exempt per WCAG 1.4.11 — the scan skips any element under a wrapper with data-state containing "disabled".
Complementary build-time check
Before tests even run, scripts/check-token-contrast.ts walks the resolved per-theme CSS in dist/css/, finds custom properties matching the same track/thumb/rail/fill patterns, and verifies they contrast at least 3:1 against the theme's --surface-canvas. This is a static safety net that catches token authoring errors at build time — before they reach the test phase.
How to fix a gap
- The violation report lists theme + subvariant + state + the exact element class + measured ratio. Fix the offending value in the component's per-theme token file.
- Per-theme token files must hold genuinely different values — pasting one theme's value into all four defeats the architecture.
- For brand-color "on" states that under-contrast (e.g. orange on white), the fix is either a darker brand variant or an outline around the control.
API documentation
Applicable-aware completeness check on the component's documented API surface. The manifest (auto-extracted from the source) records what the component exposes; a manual docs page records how to use it. The signal scores both — but only counts capabilities the component actually declares.
What is being checked
Two layers of documentation completeness, with the score equal to the fraction of expected signals that are met:
- Source-declared capabilities (props, events, slots) — counted only if the source actually declares them. A slot-less component is not penalised for lacking slots.
- Docs page — always expected. Every component should have a manual reference page under server/pages/components/.
Why "applicable-aware"
Earlier versions of this signal docked components for "missing slots" even when slot-less was the correct design. That was a false negative — the manifest correctly reflects what the source declares, so an empty `slots: []` means the template has no `<slot>`, not a docs gap. The current scoring respects design intent: ToggleSwitch has no slots, doesn't need any, and scores accordingly.
How it is tested
scripts/lib/emit-manifest.ts walks src/components/, parses each .vue file, and emits dist/manifest.json with one entry per component. The extractor records:
- props — from `defineProps<...>()` or props option
- events — from `defineEmits<...>()`
- slots — from `<slot>` and `<slot name="...">` in the template
- docsPath — derived from existence of a page under server/pages/components/<area>/<kebab>.vue
The health scorer reads the manifest and computes apiDocs = (signals met) / (signals expected). For each source-derived signal, "expected" = the source declared it, "met" = the manifest has data for it. For docsPath, "expected" is always true.
How to fix a gap
- Missing docs page → add server/pages/components/<area>/<kebab>.vue following the existing pattern (PageHeader + ComponentDemo).
- Manifest extraction missed something → that's a build bug worth fixing in scripts/lib/emit-manifest.ts, not in the component.