diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..f2cc0a9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,27 @@ +--- +name: Bug report +about: Report incorrect audit results or crashes +title: '' +labels: bug +assignees: '' +--- + +**URL audited** (if public): + +**Expected behavior:** + +**Actual behavior:** + +**Steps to reproduce:** + +```bash +npx design-auditor [options] +``` + +**Environment:** + +- Node.js version: +- OS: +- design-auditor version: + +**Additional context:** diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..e71193b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,15 @@ +--- +name: Feature request +about: Suggest a new audit rule or improvement +title: '' +labels: enhancement +assignees: '' +--- + +**What design problem would this solve?** + +**Proposed rule/check:** + +**Example of the issue on a real site (optional):** + +**Additional context:** diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d6da63f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + push: + branches: [main, dev] + pull_request: + branches: [main] + +jobs: + build-and-test: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [18.x, 20.x, 22.x] + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - run: npm ci + - run: npm run build + - run: npm run format:check + - run: npm test diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d9a537b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +## [1.0.1] - 2026-03-06 + +### Fixed + +- Links rule: duplicate `:visited` condition +- Colors extractor: CSS variable coverage now scans `CSSStyleRule.style.getPropertyValue()` across all stylesheets +- Typography rules: added `pass` violation for modular scale check +- Typography extractor: filter out `display:none` / `visibility:hidden` elements +- Reading width: removed `section` and `main` from text tags (layout containers caused false positives) +- Breakpoints extractor: use `matchAll()` to capture both values in complex queries + +### Added + +- Test suite with Vitest (unit + rule tests) +- CI workflow (build, format, test on Node 18/20/22) +- CONTRIBUTING.md + +## [1.0.0] - 2026-02-27 + +### Added + +- Initial release +- 9 audit modules: typography, colors, vertical rhythm, components, reading width, images, links, breakpoints, headings +- Terminal reporter with color swatches +- JSON report export (`--save-report`) +- Module filtering (`--only`) +- Weighted scoring system (A-F grades) +- WCAG contrast checking (AA) +- Delta-E color clustering +- Modular scale detection +- 4px/8px grid detection +- Known breakpoint system detection (Tailwind, Bootstrap, etc.) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..e120ade --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,36 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +## Our Standards + +Examples of behavior that contributes to a positive environment: + +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community + +Examples of unacceptable behavior: + +- Trolling, insulting/derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information without explicit permission +- Other conduct which could reasonably be considered inappropriate + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the project maintainer. All complaints will be reviewed and +investigated promptly and fairly. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.0. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..96d4d4e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,71 @@ +# Contributing to design-auditor + +Thanks for your interest in contributing! Here's how to get started. + +## Development Setup + +```bash +git clone https://github.com/PashaSchool/design-auditor.git +cd design-auditor +npm install +``` + +## Running Locally + +```bash +# Run against a URL +npm run dev -- https://example.com + +# Run tests +npm test + +# Check formatting +npm run format:check + +# Auto-format +npm run format +``` + +## Project Structure + +``` +src/ + extractors/ — Playwright page.evaluate() data collection + rules/ — Violation checks (pass/warn/error) + reporters/ — Terminal + JSON output + utils/ — Color math, score calculation + types.ts — Core types (Violation, ModuleReport) +``` + +**Data flow:** URL -> Extractor (browser) -> Rules (pure functions) -> Reporter (terminal/JSON) + +## Adding a New Rule + +1. Create an extractor in `src/extractors/` that collects data via `page.evaluate()` +2. Create a rule in `src/rules/` — a pure function that takes extracted data and returns `Violation[]` +3. Add tests in `tests/rules/` with mock data (no browser needed) +4. Wire it up in `src/index.ts` + +Rules should always push a `pass` violation when things are OK — this affects the score denominator. + +## Code Style + +- TypeScript strict mode +- Prettier for formatting (run `npm run format` before committing) +- No lint warnings in CI + +## Pull Requests + +1. Fork the repo and create a feature branch +2. Make your changes +3. Add/update tests +4. Run `npm test && npm run format:check && npm run build` +5. Open a PR with a clear description of the change + +## Reporting Bugs + +Open an issue with: + +- The URL you audited (if public) +- Expected vs actual behavior +- Node.js version and OS diff --git a/README.md b/README.md index 5e82472..21d8770 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ **Catch design inconsistencies before your users do.** [![npm version](https://img.shields.io/npm/v/design-auditor?color=6366f1&style=flat-square)](https://www.npmjs.com/package/design-auditor) +[![npm downloads](https://img.shields.io/npm/dm/design-auditor?color=6366f1&style=flat-square)](https://www.npmjs.com/package/design-auditor) +[![CI](https://img.shields.io/github/actions/workflow/status/PashaSchool/design-auditor/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/PashaSchool/design-auditor/actions/workflows/ci.yml) [![license](https://img.shields.io/npm/l/design-auditor?color=6366f1&style=flat-square)](LICENSE) [![snyk](https://snyk.io/test/github/PashaSchool/design-auditor/badge.svg)](https://snyk.io/test/github/PashaSchool/design-auditor) [![playwright](https://img.shields.io/badge/powered%20by-Playwright-45ba4b?style=flat-square)](https://playwright.dev) diff --git a/docs/index.html b/docs/index.html index b81ccff..ca282dc 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1,1950 +1,1957 @@ - - - - - - - - - design-auditor — Catch design inconsistencies before your users do - - - - - - - - - - - - - - - - - -
-
-
- - v1.0.0  ·  Open Source -  ·  MIT -
- -

- Your design system
has bugs.
Find them. -

- -

- design-auditor crawls any website with a real browser - and checks typography, colors, spacing, and components against design - system best practices. -

- -
- $ - npx design-auditor https://yoursite.com - -
- -
- 9 modules - 30+ checks - WCAG AA/AAA - 0 config needed - Node.js 18+ -
- - -
- -
-
-
-
-
-
-
bash — design-auditor
+ + + + + + + +
-
-
- -
-
-
// what it checks
-

9 modules. 30+ checks.
Zero config.

-

- Every rule is grounded in established standards — WCAG 2.1, 8pt grid, - modular scale, delta-E color science, and 60-30-10 color theory. -

- -
-
-
Aa
-
Typography
-
    -
  • Font family count (max 3)
  • -
  • Unique sizes & modular scale
  • -
  • Line-height consistency
  • -
-
- -
-
- - - - - - - - - + + +
+
+
+ + v1.0.0  ·  Open Source +  ·  MIT +
+ +

+ Your design system
has bugs.
Find them. +

+ +

+ design-auditor crawls any website with a real browser + and checks typography, colors, spacing, and components against design + system best practices. +

+ +
+ $ + npx design-auditor https://yoursite.com + +
+ +
+ 9 modules + 30+ checks + WCAG AA/AAA + 0 config needed + Node.js 18+ +
+ +
-
Colors
-
    -
  • Delta-E similar color clusters
  • -
  • WCAG AA & AAA contrast
  • -
  • CSS variable coverage
  • -
-
-
-
- - - - - - +
+
+
+
+
+
+
bash — design-auditor
+
+
+
-
Spacing
-
    -
  • 4px / 8px grid detection
  • -
  • Margin & padding outliers
  • -
  • Vertical rhythm analysis
  • -
-
-
- - - - +
+
+
// what it checks
+

9 modules. 30+ checks.
Zero config.

+

+ Every rule is grounded in established standards — WCAG 2.1, 8pt grid, + modular scale, delta-E color science, and 60-30-10 color theory. +

+ +
+
+
Aa
+
Typography
+
    +
  • Font family count (max 3)
  • +
  • Unique sizes & modular scale
  • +
  • Line-height consistency
  • +
+
+ +
+
+ + + + + + + + + +
+
Colors
+
    +
  • Delta-E similar color clusters
  • +
  • WCAG AA & AAA contrast
  • +
  • CSS variable coverage
  • +
+
+ +
+
+ + + + + + +
+
Spacing
+
    +
  • 4px / 8px grid detection
  • +
  • Margin & padding outliers
  • +
  • Vertical rhythm analysis
  • +
+
+ +
+
+ + + + +
+
Components
+
    +
  • Touch targets (≥ 44px)
  • +
  • :hover & :focus states
  • +
  • Border-radius & shadow system
  • +
+
+ +
+
H₁
+
Headings
+
    +
  • Single H1 per page
  • +
  • No skipped levels (H1→H2→H3)
  • +
  • Visual size hierarchy
  • +
+
+ +
+
+ + + + + +
+
Images
+
    +
  • Alt text coverage (WCAG 1.1.1)
  • +
  • Aspect ratio consistency
  • +
+
+ +
+
+ + + + + +
+
Links
+
    +
  • :focus & :visited states
  • +
  • WCAG 1.4.1 visual indicator
  • +
  • Color consistency
  • +
+
+ +
+
+ + + + + +
+
Breakpoints
+
    +
  • Media query detection
  • +
  • Mobile-first strategy
  • +
  • Framework recognition (Tailwind…)
  • +
+
+ +
+
+
Reading Width
+
    +
  • Line length (45–75 chars optimal)
  • +
  • Body text readability score
  • +
+
-
Components
-
    -
  • Touch targets (≥ 44px)
  • -
  • :hover & :focus states
  • -
  • Border-radius & shadow system
  • -
-
- -
-
H₁
-
Headings
-
    -
  • Single H1 per page
  • -
  • No skipped levels (H1→H2→H3)
  • -
  • Visual size hierarchy
  • -
-
- -
-
- - - - - +
+ +
+
+
// how it works
+

Real browser.
Real computed styles.

+

+ Unlike static CSS analysis, design-auditor uses Playwright — so it + catches JavaScript-injected styles, CSS custom properties resolved at + runtime, and third-party scripts. +

+ +
+
+
01
+
Enter URL
+
+ Any public or local dev server. No config, no API keys, no sign-up. +
+
npx design-auditor
+
+
+
02
+
Browser opens
+
+ Playwright launches Chromium, navigates to the page, and waits for + full render. +
+
Chromium / Playwright
+
+
+
03
+
Styles extracted
+
+ getComputedStyle() runs on every element. Actual rendered values — + no guessing. +
+
getComputedStyle()
+
+
+
04
+
Rules applied
+
+ 9 modules run their checks. Each produces pass / warn / error + violations. +
+
30+ rules
+
+
+
05
+
Report printed
+
+ Score 0–100 per module. Grade A–F overall. Optional JSON for CI + pipelines. +
+
Terminal + JSON
+
-
Images
-
    -
  • Alt text coverage (WCAG 1.1.1)
  • -
  • Aspect ratio consistency
  • -
-
- -
-
- - + +
+
+
// security
+

Scanned & verified.

+

+ All dependencies are continuously monitored for vulnerabilities using + Snyk. We take security seriously so you don't have to worry. +

+ +
+ + Snyk Security Status - - - -
-
Links
-
    -
  • :focus & :visited states
  • -
  • WCAG 1.4.1 visual indicator
  • -
  • Color consistency
  • -
-
- -
-
- - - - - -
-
Breakpoints
-
    -
  • Media query detection
  • -
  • Mobile-first strategy
  • -
  • Framework recognition (Tailwind…)
  • -
-
- -
-
-
Reading Width
-
    -
  • Line length (45–75 chars optimal)
  • -
  • Body text readability score
  • -
-
-
- - -
-
-
// how it works
-

Real browser.
Real computed styles.

-

- Unlike static CSS analysis, design-auditor uses Playwright — so it - catches JavaScript-injected styles, CSS custom properties resolved at - runtime, and third-party scripts. -

- -
-
-
01
-
Enter URL
-
- Any public or local dev server. No config, no API keys, no sign-up. -
-
npx design-auditor
-
-
-
02
-
Browser opens
-
- Playwright launches Chromium, navigates to the page, and waits for - full render. -
-
Chromium / Playwright
-
-
-
-
04
-
Rules applied
-
- 9 modules run their checks. Each produces pass / warn / error - violations. -
-
30+ rules
-
-
-
05
-
Report printed
-
- Score 0–100 per module. Grade A–F overall. Optional JSON for CI - pipelines. -
-
Terminal + JSON
-
-
-
- -
-
-
// security
-

Scanned & verified.

-

- All dependencies are continuously monitored for vulnerabilities using - Snyk. We take security seriously so you don't have to worry. -

- -
- - Snyk Security Status - -
-
- -
-
-
// quick start
-

No install.
Just run it.

-

- Node.js 18+ required. Playwright browser downloads automatically on - first run. -

- -
-
-
common usage
-
# Any public website
+    
+ +
+
+
// quick start
+

No install.
Just run it.

+

+ Node.js 18+ required. Playwright browser downloads automatically on + first run. +

+ +
+
+
common usage
+
# Any public website
 npx design-auditor https://yoursite.com
 
 # Save full report as JSON
@@ -1953,309 +1960,311 @@ 

No install.
Just run it.

# Local dev server mode npx design-auditor http://localhost:3000 --local
-
-
-
- selective auditing -
-
# Run specific modules only
+        
+
+
+ selective auditing +
+
# Run specific modules only
 npx design-auditor https://yoursite.com \
   --only typography,colors
 
 # Install globally
 npm install -g design-auditor
 design-auditor https://yoursite.com
+
+
+
+ +
+
// ready?
+

Audit your site in 30 seconds.

+

+ Free. Open source. No telemetry. No account. Just run it. +

+
- - - -
-
// ready?
-

Audit your site in 30 seconds.

-

- Free. Open source. No telemetry. No account. Just run it. -

- -
- - - - - + + + + diff --git a/package-lock.json b/package-lock.json index 33addcf..d0e8b92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,8 @@ "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "tsx": "^4.0.0", - "typescript": "^5.0.0" + "typescript": "^5.0.0", + "vitest": "^4.0.18" }, "engines": { "node": ">=18.0.0" @@ -45,6 +46,13 @@ "node": ">=18" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -83,6 +91,388 @@ "node": ">= 8" } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.12", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.12.tgz", @@ -93,6 +483,117 @@ "undici-types": "~6.21.0" } }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -129,6 +630,16 @@ "node": ">=8" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -155,6 +666,16 @@ "node": ">=8" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -247,6 +768,13 @@ "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "license": "MIT" }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -289,6 +817,26 @@ "@esbuild/win32-x64": "0.27.3" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -510,6 +1058,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -560,6 +1118,25 @@ "url": "https://github.com/sponsors/raouldeheer" } }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -570,6 +1147,17 @@ "node": ">=0.10.0" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/onetime": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", @@ -618,6 +1206,20 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -674,6 +1276,35 @@ "node": ">=12" } }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/prettier": { "version": "3.8.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", @@ -771,6 +1402,51 @@ "node": ">=0.10.0" } }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -795,6 +1471,13 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -817,6 +1500,30 @@ "node": ">=8" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -861,6 +1568,81 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -961,6 +1743,235 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } } } } diff --git a/package.json b/package.json index 678c6d0..7c264d6 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "dev": "tsx src/index.ts", "build": "tsc && tsc-alias", "format": "prettier --write .", - "format:check": "prettier --check ." + "format:check": "prettier --check .", + "test": "vitest run" }, "keywords": [ "design-system", @@ -53,7 +54,8 @@ "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "tsx": "^4.0.0", - "typescript": "^5.0.0" + "typescript": "^5.0.0", + "vitest": "^4.0.18" }, "files": [ "dist", diff --git a/tests/rules/breakpoints.test.ts b/tests/rules/breakpoints.test.ts new file mode 100644 index 0000000..eaf411d --- /dev/null +++ b/tests/rules/breakpoints.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; +import { checkBreakpoints } from '@rules/breakpoints.rules.js'; +import type { BreakpointsData } from '@extractors/breakpoints.js'; + +function makeData(overrides: Partial = {}): BreakpointsData { + return { + breakpoints: [ + { value: 640, query: '(min-width: 640px)', type: 'min-width', count: 5 }, + { value: 768, query: '(min-width: 768px)', type: 'min-width', count: 8 }, + { + value: 1024, + query: '(min-width: 1024px)', + type: 'min-width', + count: 3, + }, + ], + uniqueValues: [640, 768, 1024], + strategy: 'mobile-first', + knownSystem: null, + ...overrides, + }; +} + +describe('checkBreakpoints', () => { + it('warns when no breakpoints found', () => { + const v = checkBreakpoints( + makeData({ breakpoints: [], uniqueValues: [], strategy: 'none' }) + ); + expect(v).toContainEqual( + expect.objectContaining({ id: 'breakpoints-none', severity: 'warn' }) + ); + }); + + it('passes with mobile-first strategy', () => { + const v = checkBreakpoints(makeData()); + expect(v).toContainEqual( + expect.objectContaining({ + id: 'breakpoints-mobile-first', + severity: 'pass', + }) + ); + }); + + it('warns with desktop-first strategy', () => { + const v = checkBreakpoints(makeData({ strategy: 'desktop-first' })); + expect(v).toContainEqual( + expect.objectContaining({ + id: 'breakpoints-desktop-first', + severity: 'warn', + }) + ); + }); + + it('warns with mixed strategy', () => { + const v = checkBreakpoints(makeData({ strategy: 'mixed' })); + expect(v).toContainEqual( + expect.objectContaining({ + id: 'breakpoints-mixed-strategy', + severity: 'warn', + }) + ); + }); + + it('passes with 4-6 breakpoints', () => { + const v = checkBreakpoints( + makeData({ uniqueValues: [480, 640, 768, 1024] }) + ); + expect(v).toContainEqual( + expect.objectContaining({ + id: 'breakpoints-count-ok', + severity: 'pass', + }) + ); + }); + + it('errors with 9+ breakpoints', () => { + const values = [320, 375, 480, 576, 640, 768, 900, 1024, 1200]; + const v = checkBreakpoints(makeData({ uniqueValues: values })); + expect(v).toContainEqual( + expect.objectContaining({ + id: 'too-many-breakpoints', + severity: 'error', + }) + ); + }); + + it('passes when known system detected', () => { + const v = checkBreakpoints(makeData({ knownSystem: 'Tailwind CSS' })); + expect(v).toContainEqual( + expect.objectContaining({ + id: 'breakpoints-system', + severity: 'pass', + }) + ); + }); +}); diff --git a/tests/rules/colors.test.ts b/tests/rules/colors.test.ts new file mode 100644 index 0000000..36bd8da --- /dev/null +++ b/tests/rules/colors.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect } from 'vitest'; +import { checkColors } from '@rules/colors.rules.js'; +import { + rgbToLab, + type RGB, + type ColorEntry, + type ColorCluster, +} from '@utils/color.js'; +import type { ColorsData } from '@extractors/colors.js'; + +function makeEntry(r: number, g: number, b: number, count = 1): ColorEntry { + const rgb: RGB = { r, g, b, a: 1 }; + return { + raw: `rgb(${r},${g},${b})`, + rgb, + lab: rgbToLab(rgb), + count, + properties: ['color'], + tags: ['div'], + }; +} + +function makeCluster(entries: ColorEntry[]): ColorCluster { + return { + representative: entries[0], + members: entries.slice(1), + totalCount: entries.reduce((s, e) => s + e.count, 0), + }; +} + +function makeData(overrides: Partial = {}): ColorsData { + return { + all: [makeEntry(26, 115, 232, 50)], + clusters: [], + brandColors: [], + contrastPairs: [], + cssVarCoverage: 50, + ...overrides, + }; +} + +describe('checkColors', () => { + describe('unique color count', () => { + it('passes with <= 15 colors', () => { + const all = Array.from({ length: 10 }, (_, i) => makeEntry(i * 25, 0, 0)); + const v = checkColors(makeData({ all })); + expect(v).toContainEqual( + expect.objectContaining({ id: 'colors-count-ok', severity: 'pass' }) + ); + }); + + it('warns with 16-30 colors', () => { + const all = Array.from({ length: 20 }, (_, i) => + makeEntry(i * 12, i * 5, 0) + ); + const v = checkColors(makeData({ all })); + expect(v).toContainEqual( + expect.objectContaining({ id: 'many-colors', severity: 'warn' }) + ); + }); + + it('errors with 31+ colors', () => { + const all = Array.from({ length: 35 }, (_, i) => + makeEntry(i * 7, i * 3, i * 2) + ); + const v = checkColors(makeData({ all })); + expect(v).toContainEqual( + expect.objectContaining({ id: 'too-many-colors', severity: 'error' }) + ); + }); + }); + + describe('WCAG contrast', () => { + it('passes when all pairs pass AA', () => { + const v = checkColors( + makeData({ + contrastPairs: [ + { + textColor: 'rgb(0,0,0)', + bgColor: 'rgb(255,255,255)', + textHex: '#000000', + bgHex: '#ffffff', + contrast: 21, + tag: 'p', + fontSize: 16, + isBold: false, + passesAA: true, + passesAAA: true, + }, + ], + }) + ); + expect(v).toContainEqual( + expect.objectContaining({ id: 'contrast-ok', severity: 'pass' }) + ); + }); + + it('errors with 6+ failing pairs', () => { + const pairs = Array.from({ length: 7 }, (_, i) => ({ + textColor: `rgb(${200 + i},${200 + i},${200 + i})`, + bgColor: 'rgb(255,255,255)', + textHex: `#c${i}c${i}c${i}`, + bgHex: '#ffffff', + contrast: 1.5, + tag: 'p', + fontSize: 16, + isBold: false, + passesAA: false, + passesAAA: false, + })); + const v = checkColors(makeData({ contrastPairs: pairs })); + expect(v).toContainEqual( + expect.objectContaining({ id: 'contrast-fail', severity: 'error' }) + ); + }); + }); + + describe('CSS variable coverage', () => { + it('warns when coverage < 20%', () => { + const v = checkColors(makeData({ cssVarCoverage: 10 })); + expect(v).toContainEqual( + expect.objectContaining({ id: 'css-vars-low', severity: 'warn' }) + ); + }); + + it('passes when coverage >= 20%', () => { + const v = checkColors(makeData({ cssVarCoverage: 50 })); + expect(v).toContainEqual( + expect.objectContaining({ id: 'css-vars-ok', severity: 'pass' }) + ); + }); + }); + + describe('color balance', () => { + it('passes with 60/30/10 balance', () => { + const primary = makeEntry(26, 115, 232, 60); + const secondary = makeEntry(100, 100, 100, 30); + const accent = makeEntry(255, 0, 0, 10); + const v = checkColors( + makeData({ + brandColors: [ + makeCluster([primary]), + makeCluster([secondary]), + makeCluster([accent]), + ], + }) + ); + expect(v).toContainEqual( + expect.objectContaining({ + id: 'color-balance-ok', + severity: 'pass', + }) + ); + }); + + it('warns with unbalanced distribution', () => { + // p=80%, s=15%, a=5% → p>70 triggers warn + const primary = makeEntry(26, 115, 232, 80); + const secondary = makeEntry(100, 100, 100, 15); + const accent = makeEntry(255, 0, 0, 5); + const v = checkColors( + makeData({ + brandColors: [ + makeCluster([primary]), + makeCluster([secondary]), + makeCluster([accent]), + ], + }) + ); + expect(v).toContainEqual( + expect.objectContaining({ id: 'color-balance', severity: 'warn' }) + ); + }); + }); +}); diff --git a/tests/rules/components.test.ts b/tests/rules/components.test.ts new file mode 100644 index 0000000..d3ad4b6 --- /dev/null +++ b/tests/rules/components.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect } from 'vitest'; +import { checkComponents } from '@rules/components.rules.js'; +import type { ComponentsData, ButtonData } from '@extractors/components.js'; + +function makeButton(overrides: Partial = {}): ButtonData { + return { + tag: 'button', + text: 'Click me', + width: 120, + height: 48, + paddingTop: 12, + paddingRight: 24, + paddingBottom: 12, + paddingLeft: 24, + borderRadius: '8px', + fontSize: 16, + fontWeight: '500', + backgroundColor: 'rgb(26,115,232)', + color: 'rgb(255,255,255)', + cursor: 'pointer', + hasHoverStyle: true, + hasFocusStyle: true, + hasDisabledStyle: false, + ...overrides, + }; +} + +function makeData(overrides: Partial = {}): ComponentsData { + return { + buttons: [makeButton()], + uniqueRadii: ['8px'], + radiiByElement: {}, + shadows: [], + zIndices: [], + ...overrides, + }; +} + +describe('checkComponents', () => { + describe('touch targets', () => { + it('passes when all buttons >= 44px', () => { + const v = checkComponents(makeData()); + expect(v).toContainEqual( + expect.objectContaining({ id: 'touch-targets-ok', severity: 'pass' }) + ); + }); + + it('warns when some buttons < 44px', () => { + const v = checkComponents( + makeData({ + buttons: [ + makeButton({ height: 48 }), + makeButton({ height: 48 }), + makeButton({ height: 48 }), + makeButton({ height: 32, text: 'Small' }), + ], + }) + ); + expect(v).toContainEqual( + expect.objectContaining({ id: 'touch-targets', severity: 'warn' }) + ); + }); + + it('errors when >30% buttons too small', () => { + const v = checkComponents( + makeData({ + buttons: [ + makeButton({ height: 32 }), + makeButton({ height: 30 }), + makeButton({ height: 48 }), + ], + }) + ); + expect(v).toContainEqual( + expect.objectContaining({ id: 'touch-targets', severity: 'error' }) + ); + }); + }); + + describe('interactive states', () => { + it('passes with hover and focus', () => { + const v = checkComponents(makeData()); + expect(v).toContainEqual( + expect.objectContaining({ + id: 'interactive-states-ok', + severity: 'pass', + }) + ); + }); + + it('errors when focus missing', () => { + const v = checkComponents( + makeData({ + buttons: [makeButton({ hasFocusStyle: false })], + }) + ); + expect(v).toContainEqual( + expect.objectContaining({ id: 'no-focus-styles', severity: 'error' }) + ); + }); + }); + + describe('border-radius', () => { + it('passes with <= 4 radii', () => { + const v = checkComponents( + makeData({ uniqueRadii: ['4px', '8px', '12px'] }) + ); + expect(v).toContainEqual( + expect.objectContaining({ id: 'radii-ok', severity: 'pass' }) + ); + }); + + it('errors with 7+ radii', () => { + const radii = ['2px', '4px', '6px', '8px', '10px', '12px', '16px']; + const v = checkComponents(makeData({ uniqueRadii: radii })); + expect(v).toContainEqual( + expect.objectContaining({ id: 'too-many-radii', severity: 'error' }) + ); + }); + + it('ignores 50% circles', () => { + const radii = ['4px', '8px', '50%']; + const v = checkComponents(makeData({ uniqueRadii: radii })); + expect(v).toContainEqual( + expect.objectContaining({ id: 'radii-ok', severity: 'pass' }) + ); + }); + }); + + describe('shadows', () => { + it('passes with no shadows', () => { + const v = checkComponents(makeData({ shadows: [] })); + expect(v).toContainEqual( + expect.objectContaining({ id: 'no-shadows', severity: 'pass' }) + ); + }); + + it('errors with 9+ shadows', () => { + const shadows = Array.from({ length: 9 }, (_, i) => ({ + value: `0 ${i + 1}px ${i * 2}px rgba(0,0,0,0.1)`, + offsetY: i + 1, + blur: i * 2, + spread: 0, + count: 5, + tags: ['div'], + })); + const v = checkComponents(makeData({ shadows })); + expect(v).toContainEqual( + expect.objectContaining({ + id: 'too-many-shadows', + severity: 'error', + }) + ); + }); + }); + + describe('z-index', () => { + it('passes with few organized values', () => { + const v = checkComponents(makeData({ zIndices: [100, 200, 300] })); + expect(v).toContainEqual( + expect.objectContaining({ id: 'zindex-ok', severity: 'pass' }) + ); + }); + + it('warns about magic numbers', () => { + const v = checkComponents(makeData({ zIndices: [1, 100, 9001] })); + expect(v).toContainEqual( + expect.objectContaining({ id: 'zindex-magic', severity: 'warn' }) + ); + }); + + it('warns with 11+ z-index values', () => { + const zIndices = Array.from({ length: 12 }, (_, i) => i * 100); + const v = checkComponents(makeData({ zIndices })); + expect(v).toContainEqual( + expect.objectContaining({ id: 'too-many-zindex', severity: 'warn' }) + ); + }); + }); +}); diff --git a/tests/rules/headings.test.ts b/tests/rules/headings.test.ts new file mode 100644 index 0000000..7880893 --- /dev/null +++ b/tests/rules/headings.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect } from 'vitest'; +import { checkHeadings } from '@rules/headings.rules.js'; +import type { HeadingsData, HeadingEntry } from '@extractors/headings.js'; + +function makeHeading( + level: number, + text: string, + fontSize: number, + domIndex: number +): HeadingEntry { + return { + level, + tag: `h${level}`, + text, + fontSize, + fontWeight: '700', + domIndex, + }; +} + +function makeData(overrides: Partial = {}): HeadingsData { + return { + headings: [ + makeHeading(1, 'Main Title', 48, 0), + makeHeading(2, 'Subtitle', 32, 1), + makeHeading(3, 'Section', 24, 2), + ], + h1Count: 1, + skippedLevels: [], + sizeInversions: [], + levelSizes: { 1: [48], 2: [32], 3: [24] }, + hasLogicalOrder: true, + ...overrides, + }; +} + +describe('checkHeadings', () => { + it('warns when no headings found', () => { + const v = checkHeadings(makeData({ headings: [], h1Count: 0 })); + expect(v).toContainEqual( + expect.objectContaining({ id: 'headings-none', severity: 'warn' }) + ); + }); + + describe('H1 count', () => { + it('passes with exactly 1 H1', () => { + const v = checkHeadings(makeData()); + expect(v).toContainEqual( + expect.objectContaining({ id: 'h1-ok', severity: 'pass' }) + ); + }); + + it('errors with 0 H1s', () => { + const v = checkHeadings( + makeData({ + headings: [makeHeading(2, 'Sub', 32, 0)], + h1Count: 0, + }) + ); + expect(v).toContainEqual( + expect.objectContaining({ id: 'no-h1', severity: 'error' }) + ); + }); + + it('errors with multiple H1s', () => { + const v = checkHeadings( + makeData({ + headings: [ + makeHeading(1, 'First', 48, 0), + makeHeading(1, 'Second', 48, 1), + ], + h1Count: 2, + }) + ); + expect(v).toContainEqual( + expect.objectContaining({ id: 'multiple-h1', severity: 'error' }) + ); + }); + }); + + describe('hierarchy', () => { + it('passes with no skipped levels', () => { + const v = checkHeadings(makeData()); + expect(v).toContainEqual( + expect.objectContaining({ + id: 'heading-hierarchy-ok', + severity: 'pass', + }) + ); + }); + + it('errors with skipped levels', () => { + const v = checkHeadings( + makeData({ + skippedLevels: [{ from: 1, to: 3, text: 'Skipped H2' }], + }) + ); + expect(v).toContainEqual( + expect.objectContaining({ + id: 'skipped-heading-levels', + severity: 'error', + }) + ); + }); + }); + + describe('visual size hierarchy', () => { + it('passes when sizes decrease with level', () => { + const v = checkHeadings(makeData()); + expect(v).toContainEqual( + expect.objectContaining({ + id: 'heading-sizes-ok', + severity: 'pass', + }) + ); + }); + + it('errors when H2 is larger than H1', () => { + const h1 = makeHeading(1, 'Small H1', 24, 0); + const h2 = makeHeading(2, 'Big H2', 48, 1); + const v = checkHeadings( + makeData({ + sizeInversions: [{ a: h1, b: h2 }], + levelSizes: { 1: [24], 2: [48] }, + }) + ); + expect(v).toContainEqual( + expect.objectContaining({ + id: 'heading-size-inversion', + severity: 'error', + }) + ); + }); + }); + + it('warns when H1 is not first heading', () => { + const v = checkHeadings(makeData({ hasLogicalOrder: false })); + expect(v).toContainEqual( + expect.objectContaining({ id: 'h1-not-first', severity: 'warn' }) + ); + }); +}); diff --git a/tests/rules/images.test.ts b/tests/rules/images.test.ts new file mode 100644 index 0000000..f0d6bf8 --- /dev/null +++ b/tests/rules/images.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from 'vitest'; +import { checkImages } from '@rules/images.rules.js'; +import type { ImagesData, ImageEntry } from '@extractors/images.js'; + +function makeImage(overrides: Partial = {}): ImageEntry { + return { + src: 'image.jpg', + width: 800, + height: 450, + ratio: 800 / 450, + ratioLabel: '16:9', + alt: 'Description', + hasAlt: true, + context: 'article', + ...overrides, + }; +} + +function makeData(overrides: Partial = {}): ImagesData { + return { + images: [makeImage()], + uniqueRatios: ['16:9'], + missingAlt: [], + inconsistentCards: [], + ...overrides, + }; +} + +describe('checkImages', () => { + it('passes when no images', () => { + const v = checkImages(makeData({ images: [] })); + expect(v).toContainEqual( + expect.objectContaining({ id: 'images-none', severity: 'pass' }) + ); + }); + + describe('alt text', () => { + it('passes when all images have alt', () => { + const v = checkImages(makeData()); + expect(v).toContainEqual( + expect.objectContaining({ id: 'images-alt-ok', severity: 'pass' }) + ); + }); + + it('warns with few missing alt', () => { + const images = Array.from({ length: 10 }, () => makeImage()); + const missing = [makeImage({ hasAlt: false, alt: '' })]; + const v = checkImages( + makeData({ images: [...images, ...missing], missingAlt: missing }) + ); + expect(v).toContainEqual( + expect.objectContaining({ + id: 'images-some-missing-alt', + severity: 'warn', + }) + ); + }); + + it('errors when >30% missing alt', () => { + const good = [makeImage()]; + const bad = [ + makeImage({ hasAlt: false, alt: '' }), + makeImage({ hasAlt: false, alt: '' }), + ]; + const v = checkImages( + makeData({ images: [...good, ...bad], missingAlt: bad }) + ); + expect(v).toContainEqual( + expect.objectContaining({ + id: 'images-missing-alt', + severity: 'error', + }) + ); + }); + }); + + describe('aspect ratios', () => { + it('passes with <= 4 ratios', () => { + const v = checkImages(makeData({ uniqueRatios: ['16:9', '4:3', '1:1'] })); + expect(v).toContainEqual( + expect.objectContaining({ id: 'images-ratios-ok', severity: 'pass' }) + ); + }); + + it('warns with 5+ ratios', () => { + const v = checkImages( + makeData({ + uniqueRatios: ['16:9', '4:3', '1:1', '3:2', '21:9'], + }) + ); + expect(v).toContainEqual( + expect.objectContaining({ + id: 'images-many-ratios', + severity: 'warn', + }) + ); + }); + }); +}); diff --git a/tests/rules/links.test.ts b/tests/rules/links.test.ts new file mode 100644 index 0000000..1ecda6f --- /dev/null +++ b/tests/rules/links.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; +import { checkLinks } from '@rules/links.rules.js'; +import type { LinksData } from '@extractors/links.js'; + +function makeData(overrides: Partial = {}): LinksData { + return { + links: [ + { + href: 'https://example.com', + text: 'Example', + color: 'rgb(26,115,232)', + textDecoration: 'underline', + fontSize: 16, + isExternal: true, + hasUnderline: true, + hasBorder: false, + context: 'main', + }, + ], + uniqueColors: ['rgb(26,115,232)'], + noUnderlineNoAlt: [], + inconsistentColors: false, + hasVisitedStyle: true, + hasFocusStyle: true, + ...overrides, + }; +} + +describe('checkLinks', () => { + it('passes when no links found', () => { + const v = checkLinks(makeData({ links: [] })); + expect(v).toContainEqual( + expect.objectContaining({ id: 'links-none', severity: 'pass' }) + ); + }); + + it('passes with well-styled links', () => { + const v = checkLinks(makeData()); + expect(v).toContainEqual( + expect.objectContaining({ id: 'links-wcag-ok', severity: 'pass' }) + ); + expect(v).toContainEqual( + expect.objectContaining({ id: 'links-visited-ok', severity: 'pass' }) + ); + expect(v).toContainEqual( + expect.objectContaining({ id: 'links-focus-ok', severity: 'pass' }) + ); + }); + + it('errors when links distinguished by color only', () => { + const link = { + href: '#', + text: 'Bad link', + color: 'rgb(0,0,255)', + textDecoration: 'none', + fontSize: 16, + isExternal: false, + hasUnderline: false, + hasBorder: false, + context: 'main', + }; + const v = checkLinks(makeData({ noUnderlineNoAlt: [link] })); + expect(v).toContainEqual( + expect.objectContaining({ id: 'links-color-only', severity: 'error' }) + ); + }); + + it('warns when no :visited style', () => { + const v = checkLinks(makeData({ hasVisitedStyle: false })); + expect(v).toContainEqual( + expect.objectContaining({ id: 'links-no-visited', severity: 'warn' }) + ); + }); + + it('errors when no :focus style', () => { + const v = checkLinks(makeData({ hasFocusStyle: false })); + expect(v).toContainEqual( + expect.objectContaining({ id: 'links-no-focus', severity: 'error' }) + ); + }); + + it('warns with inconsistent link colors', () => { + const v = checkLinks( + makeData({ + uniqueColors: ['blue', 'red', 'green', 'purple'], + inconsistentColors: true, + }) + ); + expect(v).toContainEqual( + expect.objectContaining({ + id: 'links-inconsistent-colors', + severity: 'warn', + }) + ); + }); +}); diff --git a/tests/rules/reading-width.test.ts b/tests/rules/reading-width.test.ts new file mode 100644 index 0000000..97387cb --- /dev/null +++ b/tests/rules/reading-width.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from 'vitest'; +import { checkReadingWidth } from '@rules/reading-width.rules.js'; +import type { ReadingWidthData, TextBlock } from '@extractors/reading-width.js'; + +function makeBlock(charCount: number): TextBlock { + return { + tag: 'p', + widthPx: charCount * 8, + fontSize: 16, + charCount, + text: 'Lorem ipsum dolor sit amet', + }; +} + +function makeData(overrides: Partial = {}): ReadingWidthData { + const blocks = [makeBlock(60), makeBlock(65)]; + return { + blocks, + avgCharCount: 62, + tooWide: [], + tooNarrow: [], + optimal: blocks, + ...overrides, + }; +} + +describe('checkReadingWidth', () => { + it('passes when no text blocks', () => { + const v = checkReadingWidth( + makeData({ blocks: [], avgCharCount: 0, optimal: [] }) + ); + expect(v).toContainEqual( + expect.objectContaining({ + id: 'reading-width-no-blocks', + severity: 'pass', + }) + ); + }); + + it('passes with optimal average (45-75 chars)', () => { + const v = checkReadingWidth(makeData()); + expect(v).toContainEqual( + expect.objectContaining({ + id: 'reading-width-ok', + severity: 'pass', + }) + ); + }); + + it('warns when average 76-85 chars', () => { + const v = checkReadingWidth(makeData({ avgCharCount: 80 })); + expect(v).toContainEqual( + expect.objectContaining({ + id: 'reading-width-wide', + severity: 'warn', + }) + ); + }); + + it('errors when average > 85 chars', () => { + const v = checkReadingWidth(makeData({ avgCharCount: 95 })); + expect(v).toContainEqual( + expect.objectContaining({ + id: 'reading-width-too-wide', + severity: 'error', + }) + ); + }); + + it('warns when average < 30 chars', () => { + const v = checkReadingWidth(makeData({ avgCharCount: 25 })); + expect(v).toContainEqual( + expect.objectContaining({ + id: 'reading-width-too-narrow', + severity: 'warn', + }) + ); + }); + + it('warns when few blocks in optimal range', () => { + const blocks = Array.from({ length: 5 }, () => makeBlock(90)); + const v = checkReadingWidth( + makeData({ + blocks, + avgCharCount: 62, + optimal: [blocks[0]], + tooWide: blocks.slice(1), + }) + ); + expect(v).toContainEqual( + expect.objectContaining({ + id: 'reading-width-consistency', + severity: 'warn', + }) + ); + }); +}); diff --git a/tests/rules/rhythm.test.ts b/tests/rules/rhythm.test.ts new file mode 100644 index 0000000..b93be29 --- /dev/null +++ b/tests/rules/rhythm.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from 'vitest'; +import { checkRhythm } from '@rules/rhythm.rules.js'; +import type { RhythmData } from '@extractors/rhythm.js'; + +function makeData(overrides: Partial = {}): RhythmData { + return { + bodyFontSize: 16, + bodyLineHeight: 1.5, + rhythmUnit: 24, + lineHeights: [], + margins: [], + paddings: [], + uniqueLineHeights: [24, 48], + uniqueMargins: [8, 16, 24, 32], + uniquePaddings: [8, 16, 24], + ...overrides, + }; +} + +describe('checkRhythm', () => { + describe('rhythm unit', () => { + it('passes when rhythm unit is multiple of 4', () => { + const v = checkRhythm(makeData()); + expect(v).toContainEqual( + expect.objectContaining({ id: 'rhythm-unit-ok', severity: 'pass' }) + ); + }); + + it('warns when rhythm unit is not multiple of 4', () => { + const v = checkRhythm(makeData({ rhythmUnit: 23 })); + expect(v).toContainEqual( + expect.objectContaining({ + id: 'rhythm-unit-not-round', + severity: 'warn', + }) + ); + }); + }); + + describe('line-heights rhythm', () => { + it('passes when 80%+ match rhythm unit', () => { + const v = checkRhythm( + makeData({ uniqueLineHeights: [24, 48, 72, 96, 20] }) + ); + expect(v).toContainEqual( + expect.objectContaining({ + id: 'line-heights-rhythm-ok', + severity: 'pass', + }) + ); + }); + + it('errors when <50% match rhythm unit', () => { + const v = checkRhythm( + makeData({ uniqueLineHeights: [17, 19, 22, 25, 24] }) + ); + expect(v).toContainEqual( + expect.objectContaining({ + id: 'line-heights-rhythm-error', + severity: 'error', + }) + ); + }); + }); + + describe('spacing grid', () => { + it('passes when spacing follows 8px grid', () => { + const v = checkRhythm( + makeData({ + uniqueMargins: [8, 16, 24, 32, 48], + uniquePaddings: [8, 16, 24], + }) + ); + expect(v).toContainEqual( + expect.objectContaining({ id: 'spacing-grid-ok', severity: 'pass' }) + ); + }); + + it('errors when <60% divisible by 4px', () => { + // remainder=2 mod 4 fails the ±1px tolerance check + const v = checkRhythm( + makeData({ + uniqueMargins: [6, 10, 14, 18, 22], + uniquePaddings: [2, 6, 10, 14, 18], + }) + ); + expect(v).toContainEqual( + expect.objectContaining({ + id: 'spacing-grid-error', + severity: 'error', + }) + ); + }); + }); + + describe('margin count', () => { + it('passes with <= 10 unique margins', () => { + const v = checkRhythm( + makeData({ uniqueMargins: [4, 8, 12, 16, 24, 32] }) + ); + expect(v).toContainEqual( + expect.objectContaining({ id: 'margins-ok', severity: 'pass' }) + ); + }); + + it('errors with 16+ unique margins', () => { + const margins = Array.from({ length: 18 }, (_, i) => i * 4); + const v = checkRhythm(makeData({ uniqueMargins: margins })); + expect(v).toContainEqual( + expect.objectContaining({ + id: 'too-many-margins', + severity: 'error', + }) + ); + }); + }); +}); diff --git a/tests/rules/typography.test.ts b/tests/rules/typography.test.ts new file mode 100644 index 0000000..bc42fd0 --- /dev/null +++ b/tests/rules/typography.test.ts @@ -0,0 +1,230 @@ +import { describe, it, expect } from 'vitest'; +import { checkTypography } from '@rules/typography.rules.js'; +import type { TypographyData } from '@extractors/typography.js'; + +function makeData(overrides: Partial = {}): TypographyData { + return { + fonts: [ + { + family: 'Inter', + sizes: ['16px'], + weights: ['400'], + lineHeights: ['1.5'], + usedInTags: ['p'], + count: 100, + }, + ], + allSizes: ['14px', '16px', '20px', '24px'], + allLineHeights: ['1.4', '1.5', '1.6'], + ...overrides, + }; +} + +describe('checkTypography', () => { + describe('font families', () => { + it('passes with 1 custom font', () => { + const v = checkTypography(makeData()); + expect(v).toContainEqual( + expect.objectContaining({ id: 'font-families-ok', severity: 'pass' }) + ); + }); + + it('warns with 3 custom fonts', () => { + const v = checkTypography( + makeData({ + fonts: [ + { + family: 'Inter', + sizes: [], + weights: [], + lineHeights: [], + usedInTags: [], + count: 1, + }, + { + family: 'Roboto', + sizes: [], + weights: [], + lineHeights: [], + usedInTags: [], + count: 1, + }, + { + family: 'Poppins', + sizes: [], + weights: [], + lineHeights: [], + usedInTags: [], + count: 1, + }, + ], + }) + ); + expect(v).toContainEqual( + expect.objectContaining({ id: 'many-font-families', severity: 'warn' }) + ); + }); + + it('errors with 4+ custom fonts', () => { + const v = checkTypography( + makeData({ + fonts: [ + { + family: 'Inter', + sizes: [], + weights: [], + lineHeights: [], + usedInTags: [], + count: 1, + }, + { + family: 'Roboto', + sizes: [], + weights: [], + lineHeights: [], + usedInTags: [], + count: 1, + }, + { + family: 'Poppins', + sizes: [], + weights: [], + lineHeights: [], + usedInTags: [], + count: 1, + }, + { + family: 'Montserrat', + sizes: [], + weights: [], + lineHeights: [], + usedInTags: [], + count: 1, + }, + ], + }) + ); + expect(v).toContainEqual( + expect.objectContaining({ + id: 'too-many-font-families', + severity: 'error', + }) + ); + }); + + it('ignores system fonts', () => { + const v = checkTypography( + makeData({ + fonts: [ + { + family: 'Inter', + sizes: [], + weights: [], + lineHeights: [], + usedInTags: [], + count: 1, + }, + { + family: 'Arial', + sizes: [], + weights: [], + lineHeights: [], + usedInTags: [], + count: 1, + }, + { + family: 'sans-serif', + sizes: [], + weights: [], + lineHeights: [], + usedInTags: [], + count: 1, + }, + ], + }) + ); + expect(v).toContainEqual( + expect.objectContaining({ id: 'font-families-ok', severity: 'pass' }) + ); + }); + }); + + describe('font sizes', () => { + it('passes with <= 7 sizes', () => { + const v = checkTypography( + makeData({ allSizes: ['12px', '14px', '16px', '20px', '24px'] }) + ); + expect(v).toContainEqual( + expect.objectContaining({ id: 'font-sizes-ok', severity: 'pass' }) + ); + }); + + it('warns with 8-10 sizes', () => { + const sizes = Array.from({ length: 9 }, (_, i) => `${12 + i * 2}px`); + const v = checkTypography(makeData({ allSizes: sizes })); + expect(v).toContainEqual( + expect.objectContaining({ id: 'many-font-sizes', severity: 'warn' }) + ); + }); + + it('errors with 11+ sizes', () => { + const sizes = Array.from({ length: 12 }, (_, i) => `${10 + i * 2}px`); + const v = checkTypography(makeData({ allSizes: sizes })); + expect(v).toContainEqual( + expect.objectContaining({ + id: 'too-many-font-sizes', + severity: 'error', + }) + ); + }); + }); + + describe('modular scale', () => { + it('does not warn for modular scale sizes', () => { + // Major Third (1.25): 12 → 15 → 18.75 → 23.4 + const v = checkTypography( + makeData({ allSizes: ['12px', '15px', '18.75px', '23.4px'] }) + ); + expect(v.find((v) => v.id === 'no-modular-scale')).toBeUndefined(); + }); + + it('warns for non-modular sizes', () => { + const v = checkTypography( + makeData({ allSizes: ['12px', '14px', '22px', '40px'] }) + ); + expect(v).toContainEqual( + expect.objectContaining({ id: 'no-modular-scale', severity: 'warn' }) + ); + }); + }); + + describe('line-heights', () => { + it('passes with <= 5 unique line-heights', () => { + const v = checkTypography( + makeData({ allLineHeights: ['1.2', '1.4', '1.5'] }) + ); + expect(v).toContainEqual( + expect.objectContaining({ id: 'line-heights-ok', severity: 'pass' }) + ); + }); + + it('errors with 9+ unique line-heights', () => { + const lhs = Array.from({ length: 10 }, (_, i) => `${1 + i * 0.1}`); + const v = checkTypography(makeData({ allLineHeights: lhs })); + expect(v).toContainEqual( + expect.objectContaining({ + id: 'too-many-line-heights', + severity: 'error', + }) + ); + }); + + it('ignores "normal" line-height', () => { + const lhs = ['normal', '1.2', '1.4', '1.5']; + const v = checkTypography(makeData({ allLineHeights: lhs })); + expect(v).toContainEqual( + expect.objectContaining({ id: 'line-heights-ok', severity: 'pass' }) + ); + }); + }); +}); diff --git a/tests/unit/color.test.ts b/tests/unit/color.test.ts new file mode 100644 index 0000000..8d619ed --- /dev/null +++ b/tests/unit/color.test.ts @@ -0,0 +1,252 @@ +import { describe, it, expect } from 'vitest'; +import { + parseRGB, + normalizeColor, + rgbToHex, + getLuminance, + getContrast, + rgbToLab, + deltaE, + clusterColors, + isNeutral, + isTransparent, + isNearWhite, + isNearBlack, + type RGB, + type ColorEntry, +} from '@utils/color.js'; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeColorEntry( + r: number, + g: number, + b: number, + count = 1 +): ColorEntry { + const rgb: RGB = { r, g, b, a: 1 }; + const lab = rgbToLab(rgb); + return { + raw: `rgb(${r}, ${g}, ${b})`, + rgb, + lab, + count, + properties: ['color'], + tags: ['div'], + }; +} + +// ─── parseRGB ──────────────────────────────────────────────────────────────── + +describe('parseRGB', () => { + it('parses rgb()', () => { + expect(parseRGB('rgb(26, 115, 232)')).toEqual({ + r: 26, + g: 115, + b: 232, + a: 1, + }); + }); + + it('parses rgba()', () => { + expect(parseRGB('rgba(255, 0, 0, 0.5)')).toEqual({ + r: 255, + g: 0, + b: 0, + a: 0.5, + }); + }); + + it('parses without spaces', () => { + expect(parseRGB('rgb(0,0,0)')).toEqual({ r: 0, g: 0, b: 0, a: 1 }); + }); + + it('returns null for invalid input', () => { + expect(parseRGB('invalid')).toBeNull(); + expect(parseRGB('#ff0000')).toBeNull(); + expect(parseRGB('')).toBeNull(); + }); +}); + +// ─── normalizeColor ────────────────────────────────────────────────────────── + +describe('normalizeColor', () => { + it('normalizes opaque rgba to rgb', () => { + expect(normalizeColor('rgba(255, 255, 255, 1)')).toBe('rgb(255,255,255)'); + }); + + it('keeps semi-transparent as rgba', () => { + expect(normalizeColor('rgba(0, 0, 0, 0.5)')).toBe('rgba(0,0,0,0.5)'); + }); + + it('returns original for invalid input', () => { + expect(normalizeColor('invalid')).toBe('invalid'); + }); +}); + +// ─── rgbToHex ──────────────────────────────────────────────────────────────── + +describe('rgbToHex', () => { + it('converts black', () => { + expect(rgbToHex({ r: 0, g: 0, b: 0, a: 1 })).toBe('#000000'); + }); + + it('converts white', () => { + expect(rgbToHex({ r: 255, g: 255, b: 255, a: 1 })).toBe('#ffffff'); + }); + + it('converts arbitrary color', () => { + expect(rgbToHex({ r: 26, g: 115, b: 232, a: 1 })).toBe('#1a73e8'); + }); + + it('pads single-digit hex values', () => { + expect(rgbToHex({ r: 1, g: 2, b: 3, a: 1 })).toBe('#010203'); + }); +}); + +// ─── getLuminance ──────────────────────────────────────────────────────────── + +describe('getLuminance', () => { + it('returns 0 for black', () => { + expect(getLuminance({ r: 0, g: 0, b: 0, a: 1 })).toBeCloseTo(0, 4); + }); + + it('returns 1 for white', () => { + expect(getLuminance({ r: 255, g: 255, b: 255, a: 1 })).toBeCloseTo(1, 4); + }); + + it('returns ~0.2 for mid-gray', () => { + const lum = getLuminance({ r: 128, g: 128, b: 128, a: 1 }); + expect(lum).toBeGreaterThan(0.15); + expect(lum).toBeLessThan(0.25); + }); +}); + +// ─── getContrast ───────────────────────────────────────────────────────────── + +describe('getContrast', () => { + const black: RGB = { r: 0, g: 0, b: 0, a: 1 }; + const white: RGB = { r: 255, g: 255, b: 255, a: 1 }; + + it('returns 21:1 for black on white', () => { + expect(getContrast(black, white)).toBeCloseTo(21, 0); + }); + + it('returns 1:1 for same color', () => { + expect(getContrast(white, white)).toBeCloseTo(1, 2); + }); + + it('is symmetric', () => { + const blue: RGB = { r: 0, g: 0, b: 255, a: 1 }; + expect(getContrast(blue, white)).toBeCloseTo(getContrast(white, blue), 4); + }); +}); + +// ─── rgbToLab & deltaE ────────────────────────────────────────────────────── + +describe('deltaE', () => { + it('returns 0 for identical colors', () => { + const lab = rgbToLab({ r: 100, g: 100, b: 100, a: 1 }); + expect(deltaE(lab, lab)).toBe(0); + }); + + it('returns small value for similar colors', () => { + const lab1 = rgbToLab({ r: 100, g: 100, b: 100, a: 1 }); + const lab2 = rgbToLab({ r: 105, g: 100, b: 100, a: 1 }); + expect(deltaE(lab1, lab2)).toBeLessThan(3); + }); + + it('returns large value for very different colors', () => { + const lab1 = rgbToLab({ r: 255, g: 0, b: 0, a: 1 }); + const lab2 = rgbToLab({ r: 0, g: 0, b: 255, a: 1 }); + expect(deltaE(lab1, lab2)).toBeGreaterThan(50); + }); +}); + +// ─── clusterColors ─────────────────────────────────────────────────────────── + +describe('clusterColors', () => { + it('returns empty array for no colors', () => { + expect(clusterColors([])).toEqual([]); + }); + + it('groups similar colors into one cluster', () => { + const c1 = makeColorEntry(100, 100, 100, 10); + const c2 = makeColorEntry(102, 100, 100, 5); + const clusters = clusterColors([c1, c2], 8); + expect(clusters).toHaveLength(1); + expect(clusters[0].totalCount).toBe(15); + }); + + it('keeps different colors in separate clusters', () => { + const red = makeColorEntry(255, 0, 0, 10); + const blue = makeColorEntry(0, 0, 255, 10); + const clusters = clusterColors([red, blue], 8); + expect(clusters).toHaveLength(2); + }); + + it('sorts clusters by total count descending', () => { + const c1 = makeColorEntry(255, 0, 0, 1); + const c2 = makeColorEntry(0, 0, 255, 100); + const clusters = clusterColors([c1, c2], 8); + expect(clusters[0].representative.rgb.b).toBe(255); // blue first + }); +}); + +// ─── Classification ────────────────────────────────────────────────────────── + +describe('isNeutral', () => { + it('gray is neutral', () => { + expect(isNeutral({ r: 128, g: 128, b: 128, a: 1 })).toBe(true); + }); + + it('pure blue is not neutral', () => { + expect(isNeutral({ r: 0, g: 0, b: 255, a: 1 })).toBe(false); + }); + + it('white is neutral', () => { + expect(isNeutral({ r: 255, g: 255, b: 255, a: 1 })).toBe(true); + }); +}); + +describe('isTransparent', () => { + it('a=0 is transparent', () => { + expect(isTransparent({ r: 0, g: 0, b: 0, a: 0 })).toBe(true); + }); + + it('a=0.05 is transparent', () => { + expect(isTransparent({ r: 0, g: 0, b: 0, a: 0.05 })).toBe(true); + }); + + it('a=0.5 is not transparent', () => { + expect(isTransparent({ r: 0, g: 0, b: 0, a: 0.5 })).toBe(false); + }); +}); + +describe('isNearWhite', () => { + it('rgb(255,255,255) is near white', () => { + expect(isNearWhite({ r: 255, g: 255, b: 255, a: 1 })).toBe(true); + }); + + it('rgb(245,245,245) is near white', () => { + expect(isNearWhite({ r: 245, g: 245, b: 245, a: 1 })).toBe(true); + }); + + it('rgb(200,200,200) is not near white', () => { + expect(isNearWhite({ r: 200, g: 200, b: 200, a: 1 })).toBe(false); + }); +}); + +describe('isNearBlack', () => { + it('rgb(0,0,0) is near black', () => { + expect(isNearBlack({ r: 0, g: 0, b: 0, a: 1 })).toBe(true); + }); + + it('rgb(10,10,10) is near black', () => { + expect(isNearBlack({ r: 10, g: 10, b: 10, a: 1 })).toBe(true); + }); + + it('rgb(50,50,50) is not near black', () => { + expect(isNearBlack({ r: 50, g: 50, b: 50, a: 1 })).toBe(false); + }); +}); diff --git a/tests/unit/score.test.ts b/tests/unit/score.test.ts new file mode 100644 index 0000000..9812fc5 --- /dev/null +++ b/tests/unit/score.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest'; +import { calculateScore } from '@utils/score.js'; +import type { ModuleReport } from '@/types.js'; + +function makeReport( + name: string, + pass: number, + warn: number, + error: number +): ModuleReport { + const violations = [ + ...Array.from({ length: pass }, (_, i) => ({ + id: `${name}-pass-${i}`, + severity: 'pass' as const, + message: 'ok', + })), + ...Array.from({ length: warn }, (_, i) => ({ + id: `${name}-warn-${i}`, + severity: 'warn' as const, + message: 'warning', + })), + ...Array.from({ length: error }, (_, i) => ({ + id: `${name}-error-${i}`, + severity: 'error' as const, + message: 'error', + })), + ]; + return { name, violations }; +} + +describe('calculateScore', () => { + it('returns 100 for all-pass module', () => { + const result = calculateScore([makeReport('Colors', 5, 0, 0)]); + expect(result.overall).toBe(100); + expect(result.grade).toBe('A'); + expect(result.label).toBe('Excellent'); + }); + + it('returns 0 for all-error module', () => { + const result = calculateScore([makeReport('Colors', 0, 0, 5)]); + expect(result.overall).toBe(0); + expect(result.grade).toBe('F'); + expect(result.label).toBe('Critical'); + }); + + it('returns 50 for all-warn module', () => { + const result = calculateScore([makeReport('Colors', 0, 4, 0)]); + expect(result.overall).toBe(50); + expect(result.grade).toBe('D'); + }); + + it('applies correct weights for known modules', () => { + const reports = [ + makeReport('Colors', 5, 0, 0), // weight 20, score 100 + makeReport('Typography', 0, 0, 5), // weight 15, score 0 + ]; + const result = calculateScore(reports); + // weighted: (100*20 + 0*15) / 35 = 57.14 → 57 + expect(result.overall).toBe(57); + }); + + it('handles empty violations (score = 100)', () => { + const result = calculateScore([{ name: 'Colors', violations: [] }]); + expect(result.overall).toBe(100); + }); + + it('returns correct module breakdown', () => { + const result = calculateScore([makeReport('Colors', 3, 1, 1)]); + const mod = result.modules[0]; + expect(mod.pass).toBe(3); + expect(mod.warn).toBe(1); + expect(mod.error).toBe(1); + expect(mod.weight).toBe(20); + }); + + it('grade boundaries are correct', () => { + // 90+ → A, 75-89 → B, 60-74 → C, 40-59 → D, <40 → F + expect(calculateScore([makeReport('Colors', 9, 1, 0)]).grade).toBe('A'); // 95 + expect(calculateScore([makeReport('Colors', 7, 3, 0)]).grade).toBe('B'); // 85 + expect(calculateScore([makeReport('Colors', 5, 5, 0)]).grade).toBe('B'); // 75 + expect(calculateScore([makeReport('Colors', 3, 5, 2)]).grade).toBe('D'); // (3+2.5)/10 = 55% → D + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..b749857 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config'; +import path from 'path'; + +export default defineConfig({ + test: { + globals: true, + }, + resolve: { + alias: { + '@': path.resolve(__dirname, 'src'), + '@extractors': path.resolve(__dirname, 'src/extractors'), + '@rules': path.resolve(__dirname, 'src/rules'), + '@reporters': path.resolve(__dirname, 'src/reporters'), + '@utils': path.resolve(__dirname, 'src/utils'), + }, + }, +});