[
  {
    "path": ".editorconfig",
    "content": "# EditorConfig helps developers define and maintain consistent\n# coding styles between different editors and IDEs\n# editorconfig.org\n\nroot = true\n\n[*]\n\n# Change these settings to your own preference\nindent_style = space\nindent_size = 4\n\n# We recommend you to keep these unchanged\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newline = true"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\non:\n  push:\n    branches: [master, dev]\n  pull_request:\n    branches: [master]\n\njobs:\n  node-tests:\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        node-version: [18, 20]\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-node@v4\n        with:\n          node-version: ${{ matrix.node-version }}\n      - run: npm ci\n      - run: npm run build\n      - run: npm run test:node\n\n  browser-tests:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 20\n      - run: npm ci\n      - run: npm run build\n      - name: Start http-server\n        run: npx http-server -p 8080 &\n      - name: Wait for server\n        run: npx wait-on http://localhost:8080\n      - run: npx cypress run --config video=false\n"
  },
  {
    "path": ".gitignore",
    "content": "*.log\n*.sql\n*.sqlite\n.htaccess\n.ftppass\n.host_config\n\n*.DS_Store\nehthumbs.db\nIcon?\nThumbs.db\n\n.sass-cache\nRakefile\nrsync-exclude\nnode_modules\ndist\n\n.idea\nCLAUDE.md\n*.tsbuildinfo\n"
  },
  {
    "path": ".mocharc.yml",
    "content": "spec: test/**/*.{js,cjs}\ntimeout: 10000\n"
  },
  {
    "path": ".nvmrc",
    "content": "18.20.4"
  },
  {
    "path": "LICENSE",
    "content": "The MIT License (MIT)\n\nCopyright (c) 2015 Lokesh Dhakar\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "PLAN.md",
    "content": "# PLAN.md\n\n## Phase 1: v2 — Non-breaking improvements\n\nImprovements that ship under the current API contract. No breaking changes. Existing consumers upgrade without code changes.\n\n### 1A: Fix critical bugs\n\n- **Handle white/single-color images.** When the pixel filter strips all pixels (all white, all transparent, single color), return a sensible fallback instead of `null`. `getColor()` should return the dominant remaining color (or the image's actual color if filtering removed everything). `getPalette()` should return a shorter array rather than crash.\n- **Fix variable scope leak.** `src/color-thief.js:120` — `i = uInt8Array.length` is missing `let`, creating an implicit global.\n- **Add input validation with clear error messages.** Throw descriptive errors for: missing/unloaded image elements, tainted canvases (CORS), invalid image sources. Currently these fail silently or throw cryptic browser errors.\n\n### 1B: TypeScript type definitions\n\n- Ship a `dist/color-thief.d.ts` and `dist/color-thief-node.d.ts` alongside the existing JS output.\n- Add `types` field to `package.json`.\n- No source rewrite — just hand-authored `.d.ts` files that match the current API.\n\n### 1C: Accept more input types (browser)\n\nExpand what `getColor()` and `getPalette()` accept beyond `HTMLImageElement`:\n\n- `HTMLCanvasElement`\n- `ImageData`\n- `ImageBitmap`\n\nThese are additive — existing code passing `<img>` elements still works.\n\n### 1D: Configurable pixel filtering\n\nExpose the hardcoded thresholds as optional config:\n\n- `ignoreWhite` (default `true`, current behavior) — with configurable RGB threshold (default 250)\n- `alphaThreshold` (default 125) — pixels below this alpha are skipped\n- `minSaturation` (default 0) — optional minimum saturation filter\n\nPass as an options object: `getColor(image, { quality: 10, ignoreWhite: false })`. The current positional args (`quality`, `colorCount`) continue to work for backward compat.\n\n### 1E: Update dev tooling\n\n- Upgrade ESLint v5 → v9 with flat config.\n- Update `ecmaVersion` from 2018 to current.\n- Add a CI workflow (GitHub Actions) for automated test runs on PRs.\n- Drop the misleading `color-thief.min.js` copy — or actually minify it.\n\n---\n\n## Phase 2: v3 — Breaking changes and new architecture\n\nA new major version. Different API surface, new output format, modern JS throughout. Published as a new major version with a migration guide.\n\n### 2A: TypeScript rewrite and unified codebase\n\n- Rewrite all source files in TypeScript.\n- Merge the browser and Node implementations into a single codebase with platform-specific adapters for pixel loading. The core algorithm, pixel filtering, and output formatting are shared. Only the \"get pixels from image\" step differs.\n- Single API surface for both platforms. No more class on browser / bare functions on Node.\n\n### 2B: Modern async API\n\n- Promise-based everywhere. `getColor()` and `getPalette()` return Promises on both browser and Node.\n- Drop `getColorFromUrl()`, `getColorAsync()`, and `getImageData()`. Image loading is the consumer's responsibility.\n- Replace `XMLHttpRequest` with `fetch()` if any internal HTTP calls remain.\n- Support `AbortController` / `AbortSignal` for cancellation.\n\n### 2C: Rich output format\n\nReplace bare `[r, g, b]` arrays with color objects:\n\n```\nconst color = await colorThief.getColor(image);\ncolor.rgb()    // { r, g, b }\ncolor.hex()    // '#e84d3d'\ncolor.hsl()    // { h, s, l }\ncolor.oklch()  // { l, c, h }\ncolor.array()  // [r, g, b]  (backward-compat escape hatch)\ncolor.isDark   // boolean\n```\n\n`getPalette()` returns an array of these objects.\n\n### 2D: Semantic swatches\n\nAdd a `getSwatches()` method that classifies palette colors into UI roles:\n\n- Vibrant, Muted, DarkVibrant, DarkMuted, LightVibrant, LightMuted\n- Each swatch includes a suggested text color (title and body) for accessibility.\n- Inspired by Android's Palette API / node-vibrant, but with OKLCH-based classification for better perceptual accuracy.\n\n### 2E: Web Worker support\n\n- Use `OffscreenCanvas` + `createImageBitmap` to move pixel reading and quantization off the main thread.\n- Opt-in via config: `getColor(image, { worker: true })`.\n- Fallback to synchronous main-thread processing when Workers or OffscreenCanvas are unavailable.\n\n### 2F: Lighter Node.js dependencies\n\n- Remove `sharp` as a hard dependency. It's heavy (native bindings, Docker/CI build issues).\n- Make image decoding pluggable — consumers bring their own decoder, or use a built-in lightweight default.\n- Consider `sharp` as an optional peer dependency for users who already have it.\n\n### 2G: Optional WASM backend\n\n- Ship a `@colorthief/wasm` package with the quantization algorithm compiled from Rust.\n- Same API as the pure-JS version — drop-in replacement for the core.\n- ~6x performance improvement for the compute-heavy pixel clustering step.\n- The main `colorthief` package stays pure JS with zero native dependencies.\n\n### 2H: OKLCH-native pipeline\n\n- Option to perform quantization in OKLCH color space instead of RGB.\n- Produces more perceptually distinct palettes — colors that look different to humans, not just mathematically distant in RGB.\n- Default remains RGB quantization for performance and backward compat. OKLCH mode is opt-in.\n\n### 2I: Accessibility built in\n\n- For each color in a palette, include:\n  - WCAG contrast ratios against white and black\n  - `isDark` / `isLight` boolean\n  - Suggested foreground text color (white or black) for AA compliance\n- Make this zero-config — always included in the output, no extra method calls.\n\n### 2J: Progressive extraction\n\n- For large images, return an approximate palette immediately from a downsampled pass, then refine progressively.\n- API: `getColor(image, { progressive: true })` returns an async iterator or observable that emits improving results.\n- Useful for large images, batch processing, and perceived performance.\n- No competitor currently offers this.\n"
  },
  {
    "path": "README.md",
    "content": "# Color Thief\n\n> Extract dominant colors and palettes from images in the browser and Node.js.\n\n[![npm version](https://img.shields.io/npm/v/colorthief)](https://www.npmjs.com/package/colorthief)\n[![npm bundle size](https://img.shields.io/bundlephobia/minzip/colorthief)](https://bundlephobia.com/package/colorthief)\n[![types](https://img.shields.io/npm/types/colorthief)](https://www.npmjs.com/package/colorthief)\n\n## Install\n\n```bash\nnpm install colorthief\n```\n\nOr load directly from a CDN:\n\n```html\n<script src=\"https://unpkg.com/colorthief@3/dist/umd/color-thief.global.js\"></script>\n```\n\n## Quick Start\n\n```js\nimport { getColorSync, getPaletteSync, getSwatches } from 'colorthief';\n\n// Dominant color\nconst color = getColorSync(img);\ncolor.hex();      // '#e84393'\ncolor.css();      // 'rgb(232, 67, 147)'\ncolor.isDark;     // false\ncolor.textColor;  // '#000000'\n\n// Palette\nconst palette = getPaletteSync(img, { colorCount: 6 });\npalette.forEach(c => console.log(c.hex()));\n\n// Semantic swatches (Vibrant, Muted, DarkVibrant, etc.)\nconst swatches = await getSwatches(img);\nswatches.Vibrant?.color.hex();\n```\n\n## Features\n\n- **TypeScript** — full type definitions included\n- **Browser + Node.js** — same API, both platforms\n- **Sync & async** — synchronous browser API, async for Node.js and Web Workers\n- **Live extraction** — `observe()` watches video, canvas, or img elements and emits palette updates reactively\n- **Web Workers** — offload quantization off the main thread with `worker: true`\n- **Progressive extraction** — 3-pass refinement for instant rough results\n- **OKLCH quantization** — perceptually uniform palettes via `colorSpace: 'oklch'`\n- **Semantic swatches** — Vibrant, Muted, DarkVibrant, DarkMuted, LightVibrant, LightMuted\n- **Rich Color objects** — `.hex()`, `.rgb()`, `.hsl()`, `.oklch()`, `.css()`, contrast ratios, text color recommendations\n- **WCAG contrast** — `color.contrast.white`, `color.contrast.black`, `color.contrast.foreground`\n- **AbortSignal** — cancel in-flight extractions\n- **CLI** — `colorthief photo.jpg` with JSON, CSS, and ANSI output\n- **Zero runtime dependencies**\n\n## API at a Glance\n\n| Function | Description |\n|---|---|\n| `getColorSync(source, options?)` | Dominant color (sync, browser only) |\n| `getPaletteSync(source, options?)` | Color palette (sync, browser only) |\n| `getSwatchesSync(source, options?)` | Semantic swatches (sync, browser only) |\n| `getColor(source, options?)` | Dominant color (async, browser + Node.js) |\n| `getPalette(source, options?)` | Color palette (async, browser + Node.js) |\n| `getSwatches(source, options?)` | Semantic swatches (async, browser + Node.js) |\n| `getPaletteProgressive(source, options?)` | 3-pass progressive palette (async generator) |\n| `observe(source, options)` | Watch a source and emit palette updates (browser only) |\n| `createColor(r, g, b, population)` | Build a Color object from RGB values |\n\n### Options\n\n| Option | Default | Description |\n|---|---|---|\n| `colorCount` | `10` | Number of palette colors (2–20) |\n| `quality` | `10` | Sampling rate (1 = every pixel, 10 = every 10th) |\n| `colorSpace` | `'oklch'` | Quantization space: `'rgb'` or `'oklch'` |\n| `worker` | `false` | Offload to Web Worker (browser only) |\n| `signal` | — | `AbortSignal` to cancel extraction |\n| `ignoreWhite` | `true` | Skip white pixels |\n\n### Color Object\n\n| Property / Method | Returns |\n|---|---|\n| `.rgb()` | `{ r, g, b }` |\n| `.hex()` | `'#ff8000'` |\n| `.hsl()` | `{ h, s, l }` |\n| `.oklch()` | `{ l, c, h }` |\n| `.css(format?)` | `'rgb(255, 128, 0)'`, `'hsl(…)'`, or `'oklch(…)'` |\n| `.array()` | `[r, g, b]` |\n| `.toString()` | Hex string (works in template literals) |\n| `.textColor` | `'#ffffff'` or `'#000000'` |\n| `.isDark` / `.isLight` | Boolean |\n| `.contrast` | `{ white, black, foreground }` — WCAG ratios |\n| `.population` | Raw pixel count |\n| `.proportion` | 0–1 share of total |\n\n## Browser\n\n```js\nimport { getColorSync, getPaletteSync } from 'colorthief';\n\nconst img = document.querySelector('img');\nconst color = getColorSync(img);\nconsole.log(color.hex());\n\nconst palette = getPaletteSync(img, { colorCount: 5 });\n```\n\nAccepts `HTMLImageElement`, `HTMLCanvasElement`, `HTMLVideoElement`, `ImageData`, `ImageBitmap`, and `OffscreenCanvas`.\n\n### Live extraction with observe()\n\n```js\nimport { observe } from 'colorthief';\n\n// Watch a video and update ambient lighting as it plays\nconst controller = observe(videoElement, {\n    throttle: 200,    // ms between updates\n    colorCount: 5,\n    onChange(palette) {\n        updateAmbientBackground(palette);\n    },\n});\n\n// Stop when done\ncontroller.stop();\n```\n\nWorks with `<video>`, `<canvas>`, and `<img>` elements. For images, it uses a MutationObserver to detect `src` changes. For video and canvas, it polls using requestAnimationFrame with throttle.\n\n## Node.js\n\n```js\nimport { getColor, getPalette } from 'colorthief';\n\nconst color = await getColor('/path/to/image.jpg');\nconsole.log(color.hex());\n\nconst palette = await getPalette(Buffer.from(data), { colorCount: 5 });\n```\n\nAccepts file paths and Buffers. Uses [sharp](https://sharp.pixelplumbing.com/) for image decoding.\n\n## CLI\n\n### Quick start\n\n```bash\nnpx colorthief-cli photo.jpg\n```\n\nThe `colorthief-cli` package bundles everything needed (including sharp for image\ndecoding), so it works immediately with no extra setup.\n\n### Commands\n\n```bash\n# Dominant color\ncolorthief-cli photo.jpg\n\n# Color palette\ncolorthief-cli palette photo.jpg\n\n# Semantic swatches\ncolorthief-cli swatches photo.jpg\n```\n\n### Output formats\n\n```bash\n# Default: ANSI color swatches\ncolorthief-cli photo.jpg\n# ▇▇ #e84393\n\n# JSON with full color data\ncolorthief-cli photo.jpg --json\n\n# CSS custom properties\ncolorthief-cli palette photo.jpg --css\n# :root {\n#     --color-1: #e84393;\n#     --color-2: #6c5ce7;\n# }\n```\n\n### Options\n\n```bash\ncolorthief-cli palette photo.jpg --count 5        # Number of colors (2-20)\ncolorthief-cli photo.jpg --quality 1              # Sampling quality (1=best)\ncolorthief-cli photo.jpg --color-space rgb        # Color space (rgb or oklch)\n```\n\nStdin is supported — use `-` or pipe directly:\n\n```bash\ncat photo.jpg | colorthief-cli -\n```\n\nMultiple files are supported. Output is prefixed with filenames, and `--json` wraps\nresults in an object keyed by filename.\n\n> **Note:** If you already have `colorthief` and `sharp` installed in a project, you\n> can also use `colorthief` directly as the command name (without the `-cli` suffix).\n\n## Links\n\n- [Demo page & live examples](https://lokeshdhakar.com/projects/color-thief/)\n- [GitHub](https://github.com/lokesh/color-thief)\n- [npm](https://www.npmjs.com/package/colorthief)\n\n## Contributing\n\n```bash\nnpm run build          # Build all dist formats\nnpm run test           # Run all tests (Mocha + Cypress)\nnpm run test:node      # Node tests only\nnpm run test:browser   # Browser tests (requires npm run dev)\nnpm run dev            # Start local server on port 8080\n```\n\n## Releasing\n\n```bash\n# 1. Make sure you're on master with a clean working tree\ngit status\n\n# 2. Run the full test suite\nnpm run build\nnpm run test:node\nnpm run test:browser   # requires npm run dev in another terminal\n\n# 3. Preview what will be published\nnpm pack --dry-run\n\n# 4. Tag and publish\nnpm version <major|minor|patch>   # bumps version, creates git tag\nnpm publish                       # builds via prepublishOnly, then publishes\ngit push && git push --tags\n```\n\n## License\n\n[MIT](LICENSE) - Lokesh Dhakar\n"
  },
  {
    "path": "V3.md",
    "content": "# Color Thief v3\n\n## Overview\n\nv3 is a ground-up rewrite of Color Thief in TypeScript. The library moves from a split browser/Node codebase with divergent APIs to a single unified async API that auto-detects the runtime environment. Colors are no longer returned as raw `[r, g, b]` arrays — they come back as rich `Color` objects with built-in format conversion, accessibility metadata, and perceptual color space support.\n\n---\n\n## What changed\n\n### Language and build system\n\n- **TypeScript source.** All code in `src/` is now `.ts`. Strict mode is enabled. Published `.d.ts` declarations are generated from the source (no more hand-maintained type stubs).\n- **tsup replaces microbundle.** The build produces four artifact sets:\n  - `dist/browser/` — ESM (`.js`) and CJS (`.cjs`) for browsers\n  - `dist/node/` — ESM (`.js`) and CJS (`.cjs`) for Node.js\n  - `dist/umd/` — Minified IIFE exposing a `ColorThief` global\n  - `dist/types/` — `.d.ts` and `.d.cts` declarations\n- **Package `\"type\": \"module\"`.** The package is now ESM-first. CJS consumers use the `.cjs` entry points via the conditional exports map.\n- **Conditional `exports` map.** Bundlers and runtimes that support the `exports` field in package.json will automatically resolve to the correct browser or Node build.\n\n### API shape\n\n| Concern | v2 | v3 |\n|---|---|---|\n| **Browser import** | `new ColorThief()` class, methods on prototype | Named function imports: `getColor()`, `getPalette()`, `getSwatches()`, etc. |\n| **Node import** | `require('colorthief')` returns `{ getColor, getPalette }` | Same named imports as browser: `import { getColor } from 'colorthief'` |\n| **Browser return type** | Synchronous `[r, g, b]` tuple or `null` | `Promise<Color \\| null>` |\n| **Node return type** | `Promise<[r, g, b] \\| null>` | `Promise<Color \\| null>` (same as browser) |\n| **Options** | Positional args (`img, colorCount, quality`) or options object | Single options object only: `{ colorCount, quality, ... }` |\n| **Legacy methods** | `getColorFromUrl()`, `getColorAsync()`, `getImageData()` | Removed. Use `getColor()` with a loaded image. |\n\nThe browser API defaults to async, but synchronous functions are available for browser-only use cases (see below).\n\n### Color objects\n\nv2 returned raw `[r, g, b]` arrays. v3 returns `Color` objects:\n\n```ts\nconst color = await getColor(img);\n\ncolor.rgb()       // { r: 232, g: 67, b: 147 }\ncolor.hex()       // '#e84393'\ncolor.hsl()       // { h: 330, s: 75, l: 59 }\ncolor.oklch()     // { l: 0.63, c: 0.19, h: 352 }\ncolor.array()     // [232, 67, 147]  ← v2 format, for back-compat\ncolor.toString()  // '#e84393'  — works in template literals and string contexts\ncolor.textColor   // '#000000'  — readable text color for this background\ncolor.isDark      // false\ncolor.isLight     // true\ncolor.population  // 1\ncolor.contrast    // { white: 3.42, black: 6.14, foreground: Color(0,0,0) }\n\n// Colors work directly in string contexts:\nelement.style.backgroundColor = color;          // '#e84393'\nelement.style.color = color.textColor;          // '#000000'\nconsole.log(`Dominant color: ${color}`);         // 'Dominant color: #e84393'\n```\n\n- **`toString()`** returns the hex string, so Colors work in template literals, CSS assignment, and `console.log` without calling `.hex()`.\n- **`textColor`** returns `'#ffffff'` or `'#000000'` — the readable foreground color for this background. A plain string, ready for CSS.\n- **HSL and OKLCH** are computed lazily and cached on first access.\n- **`isDark` / `isLight`** use WCAG relative luminance with a 0.179 threshold.\n- **`contrast`** provides WCAG contrast ratios against white and black, plus a suggested foreground `Color` (white or black) for readable text overlays.\n- **`population`** exposes the relative pixel count from the quantizer (always 1 for the default MMCQ quantizer; meaningful when using the WASM quantizer or a custom one).\n\n### Synchronous browser API\n\nFor browser-only use cases where you don't need Worker offloading, AbortSignal, or Node.js support, v3 provides synchronous variants:\n\n```ts\nimport { getColorSync, getPaletteSync, getSwatchesSync } from 'colorthief';\n\nconst color = getColorSync(imgElement);\nelement.style.backgroundColor = color.hex();\n\nconst palette = getPaletteSync(imgElement, { colorCount: 5 });\nconst swatches = getSwatchesSync(imgElement);\n```\n\nThese accept `BrowserSource` only (`HTMLImageElement`, `HTMLCanvasElement`, `ImageData`, `ImageBitmap`) and take a `SyncExtractionOptions` object (same as `ExtractionOptions` minus `worker`, `signal`, and `loader`). They run entirely on the main thread with no Promise overhead.\n\nUse the sync API when:\n- You want the simplest possible usage (no `await`, no async context)\n- You're in a synchronous callback or render function\n- The image is already loaded and you just want a result immediately\n\nUse the async API when:\n- You need Worker offloading, AbortSignal cancellation, or progressive extraction\n- Your source is a Node.js file path or Buffer\n- You want to use a custom loader\n\n### Per-call quantizer and loader\n\nIn addition to the global `configure()`, you can pass `quantizer` and `loader` per-call:\n\n```ts\nimport { getPalette } from 'colorthief';\nimport { WasmQuantizer } from 'colorthief/internals';\n\nconst q = new WasmQuantizer();\nawait q.init();\n\n// Use WASM quantizer for just this call:\nconst palette = await getPalette(img, { quantizer: q, colorCount: 10 });\n```\n\nPer-call options take priority over `configure()` globals. This is useful when you want the WASM quantizer for one expensive extraction but the default MMCQ for everything else, or when running different loaders for different source types.\n\n### New features\n\n#### Semantic swatches (`getSwatches()`)\n\n```ts\nconst swatches = await getSwatches(img);\nswatches.Vibrant?.color.hex()         // '#e84393'\nswatches.DarkMuted?.titleTextColor    // Color for readable title text\n```\n\nReturns a `SwatchMap` with six roles: `Vibrant`, `Muted`, `DarkVibrant`, `DarkMuted`, `LightVibrant`, `LightMuted`. Classification uses OKLCH lightness and chroma bands with weighted distance scoring (lightness 6x, chroma 3x, population 1x). Each swatch includes `titleTextColor` and `bodyTextColor` recommendations. Roles that can't be matched are `null`.\n\n#### OKLCH quantization\n\n```ts\nconst palette = await getPalette(img, { colorSpace: 'oklch' });\n```\n\nWhen `colorSpace` is set to `'oklch'`, the pixel array is converted to OKLCH (scaled to 0–255 for MMCQ compatibility) before quantization, then converted back to RGB. This produces more perceptually uniform palettes — colors that \"feel\" evenly spaced to the human eye rather than being evenly spaced in sRGB.\n\n#### Progressive extraction (`getPaletteProgressive()`)\n\n```ts\nfor await (const { palette, progress, done } of getPaletteProgressive(img)) {\n    renderPreview(palette, progress); // 0.06 → 0.25 → 1.0\n}\n```\n\nRuns three passes with increasing quality (16x skip, 4x skip, full quality). Each pass yields a `{ palette, progress, done }` result. A `setTimeout(0)` between passes yields to the main thread so the UI stays responsive during extraction of large images.\n\n#### Web Worker offloading\n\n```ts\nconst palette = await getPalette(img, { worker: true });\n```\n\nWhen `worker: true` is passed, the quantization step runs in a Web Worker using an inline Blob URL (no separate worker file to serve). If the environment doesn't support Workers, it silently falls back to the main thread. The worker manager handles message ID tracking, promise resolution, and cleanup.\n\n#### AbortSignal cancellation\n\n```ts\nconst controller = new AbortController();\nconst palette = await getPalette(img, { signal: controller.signal });\n\n// Cancel from anywhere:\ncontroller.abort();\n```\n\nAll async API functions accept an `AbortSignal`. The signal is checked before pixel loading, between progressive passes, and propagated into worker communication. An already-aborted signal rejects immediately.\n\n#### Pluggable architecture (`configure()` and per-call options)\n\n```ts\nimport { configure, getPalette } from 'colorthief';\nimport { WasmQuantizer, createNodeLoader } from 'colorthief/internals';\n\n// Global: swap the quantizer for all calls\nconst q = new WasmQuantizer();\nawait q.init();\nconfigure({ quantizer: q });\n\n// Global: swap the pixel loader for all calls\nconfigure({ loader: createNodeLoader({ decoder: myCustomDecoder }) });\n\n// Per-call: override for just this extraction\nconst palette = await getPalette(img, { quantizer: someOtherQuantizer });\n```\n\nThe `PixelLoader` and `Quantizer` interfaces are public contracts. You can replace the default MMCQ quantizer or the default sharp/canvas pixel loader with your own implementation. Per-call `quantizer` and `loader` options take priority over `configure()` globals.\n\n#### WASM quantizer backend\n\nA Rust implementation of the full MMCQ algorithm lives in `src/wasm/`. It implements:\n- 5-bit quantized 3D color histogram (32,768 bins)\n- VBox data structure with count/volume tracking\n- Median-cut splitting along the widest dimension\n- Two-phase iteration (75% by population, remainder by population x volume)\n\nThe `WasmQuantizer` TypeScript adapter flattens pixel arrays to `Uint8Array`, calls into WASM, and parses the 7-byte-per-color result format (3 bytes RGB + 4 bytes little-endian population). The WASM module must be compiled separately with `wasm-pack build --target web`.\n\n### Dependency changes\n\n| | v2 | v3 |\n|---|---|---|\n| `sharp` | Direct dependency (always installed) | Optional peer dependency (only needed for Node.js) |\n| `ndarray-pixels` | Direct dependency | Removed entirely — sharp's `.raw().toBuffer()` is used directly |\n| `@lokesh.dhakar/quantize` | Direct dependency | Direct dependency (unchanged) |\n| `typescript` | Not present | devDependency |\n| `tsup` | Not present | devDependency (replaces microbundle) |\n| `microbundle` | devDependency | Removed |\n\n### Import paths\n\nThe package has two entry points to keep the common-case autocomplete clean:\n\n```ts\n// Main — what 95% of users need\nimport { getColor, getPalette, getSwatches, createColor } from 'colorthief';\n\n// Sync browser variants\nimport { getColorSync, getPaletteSync, getSwatchesSync } from 'colorthief';\n\n// Internals — loaders, quantizers, color-space math, worker manager\nimport { MmcqQuantizer, WasmQuantizer, rgbToOklch } from 'colorthief/internals';\n```\n\n`colorthief` exports: `getColor`, `getPalette`, `getSwatches`, `getPaletteProgressive`, `configure`, `getColorSync`, `getPaletteSync`, `getSwatchesSync`, `createColor`, and all public types (including `SyncExtractionOptions`).\n\n`colorthief/internals` exports: `MmcqQuantizer`, `WasmQuantizer`, `BrowserPixelLoader`, `NodePixelLoader`, `createNodeLoader`, `classifySwatches`, color-space conversion functions, worker manager functions, and low-level pipeline functions.\n\n### File structure\n\n```\nsrc/\n  types.ts              All interfaces and type aliases\n  color.ts              Color object implementation\n  color-space.ts        RGB ↔ OKLCH conversion functions\n  pipeline.ts           Core extraction engine (replaces core.js)\n  api.ts                Public async API functions\n  sync.ts               Public sync browser-only API functions\n  swatches.ts           Semantic swatch classification\n  progressive.ts        Multi-pass progressive extraction\n  index.ts              Main entry point (re-exports)\n  internals.ts          Power-user entry point (re-exports)\n  umd.ts                UMD/IIFE entry point\n  declarations.d.ts     Ambient type declarations for untyped deps\n  loaders/\n    browser.ts          Canvas-based pixel extraction\n    node.ts             Sharp-based pixel extraction with pluggable decoder\n  quantizers/\n    mmcq.ts             MMCQ adapter (static import of @lokesh.dhakar/quantize)\n    wasm.ts             WASM quantizer adapter\n  worker/\n    worker-script.ts    Inline worker script source\n    manager.ts          Worker lifecycle and message management\n\n  # Old v2 files (kept for reference during migration):\n  color-thief.js        Browser v2 source\n  color-thief-node.js   Node v2 source\n  core.js               Shared v2 utilities\n  color-thief.d.ts      Browser v2 type stubs\n  color-thief-node.d.ts Node v2 type stubs\n\n  # WASM backend (compile separately):\n  wasm/\n    Cargo.toml\n    src/lib.rs\n```\n\n### Test changes\n\nThe test suite was rewritten:\n\n| | v2 | v3 |\n|---|---|---|\n| Node test count | 22 tests | 50 tests |\n| Test format | CommonJS (`require`) | ESM (`import`) |\n| Assertions | Check `[r,g,b]` arrays | Check `Color` objects (`.rgb()`, `.hex()`, `.isDark`, etc.) |\n\nNew test coverage areas:\n- Color object methods (rgb, hex, hsl, oklch, array, isDark, isLight, contrast, population)\n- RGB → OKLCH → RGB round-trip accuracy (9 reference colors, ±1 tolerance)\n- `getSwatches()` structure and role assignment\n- OKLCH color space quantization option\n- AbortController cancellation\n- Progressive extraction (3 passes, progress values, final palette)\n\n---\n\n## Benefits for consumers\n\n### Eliminates boilerplate color conversion code\n\nv2 returned raw `[r, g, b]` arrays, so every project using Color Thief needed its own RGB-to-hex, RGB-to-HSL, or \"is this color dark?\" utility. v3 builds all of that into the `Color` object. Common patterns that previously required 5–20 lines of helper code become a single property access:\n\n```ts\n// v2: need your own conversion\nconst [r, g, b] = colorThief.getColor(img);\nconst hex = '#' + [r, g, b].map(c => c.toString(16).padStart(2, '0')).join('');\nconst luminance = 0.2126 * (r/255) + 0.7152 * (g/255) + 0.0722 * (b/255);\nconst textColor = luminance < 0.5 ? '#fff' : '#000';\n\n// v3: built in\nconst color = await getColor(img);\nconst hex = color.hex();\nconst textColor = color.contrast.foreground.hex();\n```\n\n### First-class TypeScript support\n\nv2 shipped hand-maintained `.d.ts` files that were separate from the source and could drift. v3 generates declarations directly from the TypeScript source, so types are always accurate. All public interfaces (`Color`, `ExtractionOptions`, `SwatchMap`, `Quantizer`, etc.) are exported and documented with JSDoc.\n\n### Consistent API across platforms\n\nv2 had fundamentally different APIs on browser vs Node: the browser version was a class with synchronous methods, and the Node version was a module with async functions. You couldn't write a utility that worked in both environments without platform-specific code. v3 exports the same functions with the same signatures and return types on both platforms. Platform detection happens internally.\n\n### Smaller Node.js install\n\n`sharp` moves from a required dependency to an optional peer dependency. For browser-only projects, `npm install colorthief` no longer downloads sharp and its native binaries (which can be 30+ MB depending on platform). Node.js projects that need image decoding install sharp separately.\n\n`ndarray-pixels` is removed entirely (saved ~50 KB of dependencies). v3 uses sharp's `.raw().toBuffer()` directly.\n\n### Accessibility built in\n\nEvery `Color` object has WCAG contrast ratios and a suggested foreground color. The `getSwatches()` function returns text color recommendations per swatch. This means you can build accessible UIs (e.g. colored cards with readable text) directly from extraction results without a separate contrast-checking library.\n\n### Perceptually uniform palettes\n\nThe OKLCH quantization option (`{ colorSpace: 'oklch' }`) produces palettes that look more evenly distributed to the human eye. sRGB quantization (the default, same as v2) can over-represent greens and under-represent blues because sRGB is not perceptually uniform. OKLCH quantization fixes this.\n\n### UI responsiveness for large images\n\nProgressive extraction lets you show a rough palette instantly (6% of pixels sampled in ~1ms) and refine it as the full extraction completes. This matters for large images where full extraction can take 50–200ms. The `setTimeout(0)` yield between passes ensures the browser doesn't freeze.\n\nWeb Worker offloading moves the quantization math entirely off the main thread, eliminating jank for any image size.\n\n### Cancellable extraction\n\nLong-running extractions can be cancelled via `AbortSignal`. This is important for UIs where the user navigates away before extraction finishes — previously there was no way to abandon the work, and the callback/promise would fire after the result was no longer needed.\n\n### Extensibility\n\nThe `configure()` function, per-call `quantizer`/`loader` options, and the `Quantizer`/`PixelLoader` interfaces let power users:\n- Swap in the WASM quantizer for ~2–5x faster quantization on large palettes — globally via `configure()` or per-call via `{ quantizer: wasmQ }`\n- Use a custom image decoder (e.g. `@napi-rs/image`, `jimp`, or a GPU-accelerated decoder) instead of sharp\n- Implement a completely different quantization algorithm (k-means, octree, etc.) and plug it in\n- Mix quantizers per extraction without reconfiguring globals\n\n### Conditional exports\n\nModern bundlers (webpack 5+, Vite, Rollup, esbuild) and Node.js 16+ resolve the correct build via the `exports` map. Browser builds never include sharp or Node.js loader code. Node builds never include DOM/canvas code.\n\n---\n\n## Negatives and costs for consumers\n\n### Breaking changes — migration effort required\n\nEvery call site must be updated:\n\n1. **Import syntax changes.** `new ColorThief()` and `require('colorthief')` become `import { getColor, getPalette } from 'colorthief'`.\n2. **Return type changes.** `[r, g, b]` arrays become `Color` objects. Any code that destructures or indexes into the result (`color[0]`) will break. Use `color.array()` for the v2-style tuple.\n3. **Primary browser API is now async.** v2's synchronous `getColor(img)` becomes `await getColor(img)`. Alternatively, use `getColorSync(img)` to keep synchronous call sites — but note that the sync variants only accept browser sources and don't support Workers or AbortSignal.\n4. **Positional arguments removed.** `getPalette(img, 5, 10)` must become `getPalette(img, { colorCount: 5, quality: 10 })`.\n5. **Legacy methods gone.** `getColorFromUrl()`, `getColorAsync()`, and `getImageData()` are removed. Use `getColor()` with a loaded `HTMLImageElement`.\n\nFor projects with many call sites, this migration is non-trivial.\n\n### Two API surfaces for browser\n\nv2 had a single synchronous browser API. v3 has both async and sync variants (`getColor`/`getColorSync`, `getPalette`/`getPaletteSync`, `getSwatches`/`getSwatchesSync`). The sync functions restore v2's simplicity for browser-only use cases, but the dual surface means developers need to understand when to use which. The guidance is straightforward (sync for simple browser usage, async for Node.js/Workers/cancellation), but it's still two things to learn instead of one.\n\n### Color objects have overhead\n\nThe `Color` object is heavier than a raw `[r, g, b]` array. Each color allocates an object with methods, lazy-cached properties, and closure references. For the common case (palette of 5–20 colors), this overhead is negligible. But if you're extracting palettes from hundreds of images in a batch pipeline and only need RGB values, the object allocation is wasted work. Use `color.array()` to get the raw tuple if that's all you need.\n\n### `sharp` is no longer auto-installed\n\nv2 installed sharp as a direct dependency — `npm install colorthief` gave you a working Node.js setup. v3 makes sharp an optional peer dependency. Node.js users must now run `npm install sharp` separately, and they'll see a peer dependency warning if they don't. This is a common source of confusion for new users who copy a Node.js example and get `sharp is required for Node.js image loading` at runtime.\n\n### ESM-only package\n\nThe package now has `\"type\": \"module\"`. While CJS entry points are provided via the exports map, tools and environments that don't support the `exports` field may have trouble resolving the correct file. Older bundlers (webpack 4, older Jest configs without ESM support) may need configuration changes. The `main` field still points to a CJS file for fallback, but ESM-unaware tools may not handle the conditional exports correctly.\n\n### No synchronous Node.js API\n\nv2's Node API was already async, so this isn't a regression there. The sync functions (`getColorSync`, etc.) are browser-only — they require DOM APIs (canvas, `getImageData`) that don't exist in Node.js. If you need synchronous color extraction in a Node.js context (e.g. inside a `--loader` hook or a synchronous build step), v3 doesn't support it.\n\n### New minimum Node.js version implied\n\nThe package targets ES2020 and uses `??`, `?.`, `AbortSignal`, and `AsyncGenerator`. While it doesn't enforce an engines field, it effectively requires Node.js 16+ (and realistically 18+ for full AbortSignal support). Projects stuck on Node 14 cannot use v3.\n\n### WASM quantizer requires a separate build step\n\nThe Rust WASM quantizer is included as source code (`src/wasm/`) but is not pre-compiled. Using it requires:\n1. Installing the Rust toolchain and `wasm-pack`\n2. Running `wasm-pack build --target web` in `src/wasm/`\n3. Pointing `WasmQuantizer` at the generated `.wasm` file\n\nThis is a power-user feature and is clearly documented as such, but it's not a plug-and-play experience like the default MMCQ quantizer.\n\n### Larger browser bundle\n\nv2's browser UMD was ~9 KB unminified. v3's IIFE bundle is ~19 KB minified (including the Color object, OKLCH conversions, swatch classification, progressive extraction, and worker manager). For projects that only need basic `getColor()`, roughly half the bundle is unused features. Tree-shaking via the ESM build mitigates this — if you only import `getColor`, bundlers can eliminate the rest — but the UMD/IIFE build carries everything.\n\n### Swatch classification may return `null` for some roles\n\n`getSwatches()` returns a `SwatchMap` where any role can be `null` if no palette color falls within that role's OKLCH lightness/chroma range. For images with limited color variation (e.g. a mostly blue photo), you may get `null` for `LightVibrant` or `DarkMuted`. Consumers must null-check every swatch access. This is inherent to the classification approach, but it means you can't rely on getting all six swatches.\n\n### No UMD class wrapper\n\nv2 exposed a `ColorThief` class via the UMD build, which some projects instantiated as `new ColorThief()`. v3's UMD build exposes `ColorThief.getColor()`, `ColorThief.getPalette()`, `ColorThief.getColorSync()`, etc. as direct functions — there's no class to instantiate. This breaks any code that does `const ct = new ColorThief()`.\n\n---\n\n## Migration cheatsheet\n\n| v2 | v3 (async) | v3 (sync, browser only) |\n|---|---|---|\n| `const ct = new ColorThief()` | Remove — no class needed | Remove — no class needed |\n| `ct.getColor(img)` | `await getColor(img)` | `getColorSync(img)` |\n| `ct.getColor(img, 5)` | `await getColor(img, { quality: 5 })` | `getColorSync(img, { quality: 5 })` |\n| `ct.getPalette(img, 8)` | `await getPalette(img, { colorCount: 8 })` | `getPaletteSync(img, { colorCount: 8 })` |\n| `ct.getPalette(img, 8, 5)` | `await getPalette(img, { colorCount: 8, quality: 5 })` | `getPaletteSync(img, { colorCount: 8, quality: 5 })` |\n| `color[0]`, `color[1]`, `color[2]` | `color.array()[0]` or `color.rgb().r` | same |\n| `'#' + color.map(...)` | `color.hex()` or `color.toString()` | same |\n| `ct.getColorFromUrl(url, cb)` | Load the image yourself, then `await getColor(img)` | Load the image, then `getColorSync(img)` |\n| `require('colorthief').getColor(path)` | `import { getColor } from 'colorthief'` | N/A (sync is browser only) |\n"
  },
  {
    "path": "async.html",
    "content": "<!doctype html>\n<html class=\"no-js\" lang=\"en\">\n<head>\n\n  <script src=\"https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js\"></script>\n  <script src=\"dist/color-thief.min.js\"></script>\n\n</head>\n<body>\n\n  <header>\n    <div class=\"container\">\n      <div id=\"samedomain\">\n      <img src=\"examples/img/image-1.jpg\" width=\"400\" height=\"200\">\n        <h1>Same domain, image by url</h1>\n      </div>\n      <script type=\"text/javascript\">\n        var colorThief = new ColorThief();\n        colorSync = colorThief.getColorFromUrl(\"examples/img/image-1.jpg\", function(color){\n          $('#samedomain').css('background-color','rgb('+color[0]+','+color[1]+','+color[2]+')')\n          console.log('url',color)\n        });\n      </script>\n      <div id=\"crossdomain\">\n        <img width=\"400\" height=\"200\">\n        <h1>Cross-domain, image by url</h1>\n      </div>\n      <script type=\"text/javascript\">\n        colorThief.getColorAsync(\"https://lokeshdhakar.com/media/posts/color-thief/color-thief-pixels.png\",function(color, element){\n          $('#crossdomain').css('background-color','rgb('+color[0]+','+color[1]+','+color[2]+')')\n          $('#crossdomain img').attr('src',element.src)\n          console.log('async', color, element.src)\n        });\n      </script>\n\n    </div>\n\n\n\n\n</body>\n</html>\n"
  },
  {
    "path": "build/build.js",
    "content": "var fs = require('fs');\nconst { resolve } = require('path');\n\n/*\ncolor-thief.umd.js duplicated as color-thief.min.js for legacy support\n\nIn Color Thief v2.1 <= there was one distribution file (dist/color-thief.min.js)\nand it exposed a global variable ColorThief. Starting from v2.2, the package\nincludes multiple dist files for the various module systems. One of these is\nthe UMD format which falls back to a global variable if the requirejs AMD format\nis not being used. This file is called color-thief.umd.js in the dist folder. We\nwant to keep supporting the previous users who were loading\ndist/color-thief.min.js and expecting a global var. For this reason we're\nduplicating the UMD compatible file and giving it that name.\n\nNote: Microbundle already minifies the UMD output, so color-thief.min.js\nis genuinely minified — the copy IS minified code.\n*/\n\nconst umdRelPath = 'dist/color-thief.umd.js';\nconst legacyRelPath = 'dist/color-thief.min.js';\n\nconst umdPath = resolve(process.cwd(), umdRelPath);\nconst legacyPath = resolve(process.cwd(), legacyRelPath);\n\nfs.copyFile(umdPath, legacyPath, (err) => {\n    if (err) throw err;\n    console.log(`${umdRelPath} copied to ${legacyRelPath}.`);\n});\n\nconst srcNodeRelPath = 'src/color-thief-node.js';\nconst distNodeRelPath = 'dist/color-thief.js';\nconst srcNodePath = resolve(process.cwd(), srcNodeRelPath);\nconst distNodePath = resolve(process.cwd(), distNodeRelPath);\n\nfs.copyFile(srcNodePath, distNodePath, (err) => {\n    if (err) throw err;\n    console.log(`${srcNodeRelPath} copied to ${distNodeRelPath}.`);\n});\n\n// Copy TypeScript declaration files to dist\nconst typeCopies = [\n    ['src/color-thief.d.ts', 'dist/color-thief.d.ts'],\n    ['src/color-thief-node.d.ts', 'dist/color-thief-node.d.ts'],\n];\n\ntypeCopies.forEach(([srcRel, distRel]) => {\n    const srcPath = resolve(process.cwd(), srcRel);\n    const distPath = resolve(process.cwd(), distRel);\n    fs.copyFile(srcPath, distPath, (err) => {\n        if (err) throw err;\n        console.log(`${srcRel} copied to ${distRel}.`);\n    });\n});\n"
  },
  {
    "path": "cypress/e2e/api-direct.cy.js",
    "content": "describe('Direct API - getColorSync()', { testIsolation: false }, function() {\n    before(function() {\n        cy.visit('http://localhost:8080/cypress/test-pages/api-direct.html');\n        cy.get('body[data-ready=\"true\"]', { timeout: 10000 });\n    });\n\n    it('returns near-black for black.png', function() {\n        cy.window().then((win) => {\n            const img = win.document.getElementById('img-black');\n            const color = win.ColorThief.getColorSync(img);\n            const [r, g, b] = color.array();\n            expect(r).to.be.lessThan(10);\n            expect(g).to.be.lessThan(10);\n            expect(b).to.be.lessThan(10);\n        });\n    });\n\n    it('returns near-red for red.png', function() {\n        cy.window().then((win) => {\n            const img = win.document.getElementById('img-red');\n            const color = win.ColorThief.getColorSync(img);\n            const [r, g, b] = color.array();\n            expect(r).to.be.greaterThan(240);\n            expect(g).to.be.lessThan(15);\n            expect(b).to.be.lessThan(15);\n        });\n    });\n\n    it('returns near-white for white.png', function() {\n        cy.window().then((win) => {\n            const img = win.document.getElementById('img-white');\n            const color = win.ColorThief.getColorSync(img);\n            const [r, g, b] = color.array();\n            expect(r).to.be.greaterThan(240);\n            expect(g).to.be.greaterThan(240);\n            expect(b).to.be.greaterThan(240);\n        });\n    });\n\n    it('returns valid color for transparent.png', function() {\n        cy.window().then((win) => {\n            const img = win.document.getElementById('img-transparent');\n            const color = win.ColorThief.getColorSync(img);\n            const rgb = color.array();\n            expect(rgb).to.have.lengthOf(3);\n        });\n    });\n\n    it('respects quality parameter', function() {\n        cy.window().then((win) => {\n            const img = win.document.getElementById('img-rainbow');\n            const color1 = win.ColorThief.getColorSync(img, { quality: 1 });\n            const color100 = win.ColorThief.getColorSync(img, { quality: 100 });\n            expect(color1.array()).to.have.lengthOf(3);\n            expect(color100.array()).to.have.lengthOf(3);\n        });\n    });\n});\n\ndescribe('Direct API - getPaletteSync()', { testIsolation: false }, function() {\n    before(function() {\n        cy.visit('http://localhost:8080/cypress/test-pages/api-direct.html');\n        cy.get('body[data-ready=\"true\"]', { timeout: 10000 });\n    });\n\n    it('returns default 10 colors', function() {\n        cy.window().then((win) => {\n            const img = win.document.getElementById('img-rainbow');\n            const palette = win.ColorThief.getPaletteSync(img);\n            expect(palette).to.have.lengthOf(10);\n            palette.forEach(color => {\n                expect(color.array()).to.have.lengthOf(3);\n            });\n        });\n    });\n\n    it('returns palette with white for white.png', function() {\n        cy.window().then((win) => {\n            const img = win.document.getElementById('img-white');\n            const palette = win.ColorThief.getPaletteSync(img);\n            expect(palette).to.be.an('array').that.has.lengthOf(1);\n            const [r, g, b] = palette[0].array();\n            expect(r).to.be.greaterThan(240);\n            expect(g).to.be.greaterThan(240);\n            expect(b).to.be.greaterThan(240);\n        });\n    });\n\n    it('returns valid palette for transparent.png', function() {\n        cy.window().then((win) => {\n            const img = win.document.getElementById('img-transparent');\n            const palette = win.ColorThief.getPaletteSync(img);\n            expect(palette).to.be.an('array').that.is.not.empty;\n            palette.forEach(color => expect(color.array()).to.have.lengthOf(3));\n        });\n    });\n\n    it('throws when colorCount=1', function() {\n        cy.window().then((win) => {\n            const img = win.document.getElementById('img-rainbow');\n            expect(() => win.ColorThief.getPaletteSync(img, { colorCount: 1 })).to.throw();\n        });\n    });\n\n    it('clamps colorCount=0 to 2', function() {\n        cy.window().then((win) => {\n            const img = win.document.getElementById('img-rainbow');\n            const palette = win.ColorThief.getPaletteSync(img, { colorCount: 0 });\n            expect(palette).to.have.lengthOf(2);\n        });\n    });\n\n    it('clamps colorCount=21 to 20', function() {\n        cy.window().then((win) => {\n            const img = win.document.getElementById('img-rainbow');\n            const palette = win.ColorThief.getPaletteSync(img, { colorCount: 21 });\n            expect(palette).to.have.lengthOf(20);\n        });\n    });\n});\n\ndescribe('Direct API - Input Types', { testIsolation: false }, function() {\n    before(function() {\n        cy.visit('http://localhost:8080/cypress/test-pages/api-direct.html');\n        cy.get('body[data-ready=\"true\"]', { timeout: 10000 });\n    });\n\n    it('accepts HTMLCanvasElement input', function() {\n        cy.window().then((win) => {\n            const img = win.document.getElementById('img-red');\n            const canvas = win.document.createElement('canvas');\n            const ctx = canvas.getContext('2d');\n            canvas.width = img.naturalWidth;\n            canvas.height = img.naturalHeight;\n            ctx.drawImage(img, 0, 0);\n            const color = win.ColorThief.getColorSync(canvas);\n            const [r] = color.array();\n            expect(r).to.be.greaterThan(240);\n        });\n    });\n\n    it('accepts ImageData input', function() {\n        cy.window().then((win) => {\n            const img = win.document.getElementById('img-red');\n            const canvas = win.document.createElement('canvas');\n            const ctx = canvas.getContext('2d');\n            canvas.width = img.naturalWidth;\n            canvas.height = img.naturalHeight;\n            ctx.drawImage(img, 0, 0);\n            const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);\n            const color = win.ColorThief.getColorSync(imageData);\n            const [r] = color.array();\n            expect(r).to.be.greaterThan(240);\n        });\n    });\n\n    it('accepts ImageBitmap input', function() {\n        cy.window().then((win) => {\n            const img = win.document.getElementById('img-red');\n            return win.createImageBitmap(img).then((bitmap) => {\n                const color = win.ColorThief.getColorSync(bitmap);\n                const [r] = color.array();\n                expect(r).to.be.greaterThan(240);\n            });\n        });\n    });\n\n    it('accepts options object with HTMLCanvasElement', function() {\n        cy.window().then((win) => {\n            const img = win.document.getElementById('img-rainbow');\n            const canvas = win.document.createElement('canvas');\n            const ctx = canvas.getContext('2d');\n            canvas.width = img.naturalWidth;\n            canvas.height = img.naturalHeight;\n            ctx.drawImage(img, 0, 0);\n            const palette = win.ColorThief.getPaletteSync(canvas, { colorCount: 5 });\n            expect(palette).to.have.lengthOf(5);\n        });\n    });\n});\n"
  },
  {
    "path": "cypress/e2e/api.cy.js",
    "content": "function rgbCount(text) {\n    const vals = text.split(',');\n    for (const val of vals) {\n        if (val < 0 || val > 255) {\n            throw 'Invalid RGB color value';\n        }\n    }\n    return vals.length / 3\n}\n\ndescribe('getColorSync()', { testIsolation: false }, function() {\n    before(function() {\n        cy.visit('http://localhost:8080/cypress/test-pages/index.html');\n    })\n\n    it('returns valid color from black image', function() {\n        cy.get('[data-image=\"black.png\"] .output-color').should(($el) => {\n            const count = rgbCount($el.text())\n            expect(count).to.equal(1);\n            const [r, g, b] = $el.text().split(',').map(Number);\n            expect(r).to.be.lessThan(10);\n            expect(g).to.be.lessThan(10);\n            expect(b).to.be.lessThan(10);\n        });\n    })\n\n    it('returns valid color from red image', function() {\n        cy.get('[data-image=\"red.png\"] .output-color').should(($el) => {\n            const count = rgbCount($el.text())\n            expect(count).to.equal(1);\n            const [r, g, b] = $el.text().split(',').map(Number);\n            expect(r).to.be.greaterThan(240);\n            expect(g).to.be.lessThan(15);\n            expect(b).to.be.lessThan(15);\n        });\n    })\n\n    it('returns valid color from rainbow image', function() {\n        cy.get('[data-image=\"rainbow-horizontal.png\"] .output-color').should(($el) => {\n            const count = rgbCount($el.text())\n            expect(count).to.equal(1);\n        });\n    })\n\n    it('returns valid color from white image', function() {\n        cy.get('[data-image=\"white.png\"] .output-color').should(($el) => {\n            const count = rgbCount($el.text())\n            expect(count).to.equal(1);\n        });\n    })\n\n    it('returns valid color from transparent image', function() {\n        cy.get('[data-image=\"transparent.png\"] .output-color').should(($el) => {\n            const count = rgbCount($el.text())\n            expect(count).to.equal(1);\n        });\n    })\n})\n\nfunction testPaletteCount(num) {\n    it(`returns ${num} color when colorCount set to ${num}`, function() {\n        cy.get(`[data-image=\"rainbow-horizontal.png\"] .palette[data-count=\"${num}\"] .output-palette`).should(($el) => {\n            const count = rgbCount($el.text())\n            expect(count).to.equal(num);\n        });\n    })\n}\n\ndescribe('getPaletteSync()', function() {\n    beforeEach(function() {\n        cy.visit('http://localhost:8080/cypress/test-pages/index.html');\n    })\n\n    let testCounts = [2, 3, 5, 7, 10, 20];\n    testCounts.forEach((count) => testPaletteCount(count))\n})\n"
  },
  {
    "path": "cypress/e2e/cors.cy.js",
    "content": "describe('cross domain images with liberal CORS policy', function() {\n    it('load', function() {\n        cy.visit('http://localhost:8080/cypress/test-pages/cors.html');\n        cy.get('#result').should(($el) => {\n            const count = $el.text().split(',').length\n            expect(count).to.equal(3);\n        });\n    })\n});\n"
  },
  {
    "path": "cypress/e2e/module.cy.js",
    "content": "describe('es6 module', function() {\n    it('loads', function() {\n        cy.visit('http://localhost:8080/cypress/test-pages/es6-module.html');\n        cy.get('#result').should(($el) => {\n            const count = $el.text().split(',').length\n            expect(count).to.equal(3);\n        });\n    })\n});\n"
  },
  {
    "path": "cypress/fixtures/example.json",
    "content": "{\n  \"name\": \"Using fixtures to represent data\",\n  \"email\": \"hello@cypress.io\",\n  \"body\": \"Fixtures are a great way to mock data for responses to routes\"\n}"
  },
  {
    "path": "cypress/plugins/index.cjs",
    "content": "// ***********************************************************\n// This example plugins/index.js can be used to load plugins\n//\n// You can change the location of this file or turn off loading\n// the plugins file with the 'pluginsFile' configuration option.\n//\n// You can read more here:\n// https://on.cypress.io/plugins-guide\n// ***********************************************************\n\n// This function is called when a project is opened or re-opened (e.g. due to\n// the project's config changing)\n\nmodule.exports = (on, config) => {\n  // `on` is used to hook into various events Cypress emits\n  // `config` is the resolved Cypress config\n}\n"
  },
  {
    "path": "cypress/support/commands.js",
    "content": "// ***********************************************\n// This example commands.js shows you how to\n// create various custom commands and overwrite\n// existing commands.\n//\n// For more comprehensive examples of custom\n// commands please read more here:\n// https://on.cypress.io/custom-commands\n// ***********************************************\n//\n//\n// -- This is a parent command --\n// Cypress.Commands.add(\"login\", (email, password) => { ... })\n//\n//\n// -- This is a child command --\n// Cypress.Commands.add(\"drag\", { prevSubject: 'element'}, (subject, options) => { ... })\n//\n//\n// -- This is a dual command --\n// Cypress.Commands.add(\"dismiss\", { prevSubject: 'optional'}, (subject, options) => { ... })\n//\n//\n// -- This is will overwrite an existing command --\n// Cypress.Commands.overwrite(\"visit\", (originalFn, url, options) => { ... })\n"
  },
  {
    "path": "cypress/support/e2e.js",
    "content": "// ***********************************************************\n// This example support/index.js is processed and\n// loaded automatically before your test files.\n//\n// This is a great place to put global configuration and\n// behavior that modifies Cypress.\n//\n// You can change the location of this file or turn off\n// automatically serving support files with the\n// 'supportFile' configuration option.\n//\n// You can read more here:\n// https://on.cypress.io/configuration\n// ***********************************************************\n\n// Import commands.js using ES2015 syntax:\nimport './commands'\n\n// Alternatively you can use CommonJS syntax:\n// require('./commands')\n"
  },
  {
    "path": "cypress/test-pages/api-direct.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"utf-8\">\n    <title>Color Thief - Direct API Tests</title>\n</head>\n<body>\n    <img id=\"img-black\" src=\"./img/black.png\" crossorigin=\"anonymous\" />\n    <img id=\"img-red\" src=\"./img/red.png\" crossorigin=\"anonymous\" />\n    <img id=\"img-rainbow\" src=\"./img/rainbow-horizontal.png\" crossorigin=\"anonymous\" />\n    <img id=\"img-white\" src=\"./img/white.png\" crossorigin=\"anonymous\" />\n    <img id=\"img-transparent\" src=\"./img/transparent.png\" crossorigin=\"anonymous\" />\n\n    <script src=\"/dist/umd/color-thief.global.js\"></script>\n    <script>\n        var images = document.querySelectorAll('img');\n        var loaded = 0;\n        var total = images.length;\n\n        function checkAllLoaded() {\n            loaded++;\n            if (loaded === total) {\n                document.body.setAttribute('data-ready', 'true');\n            }\n        }\n\n        images.forEach(function(img) {\n            if (img.complete) {\n                checkAllLoaded();\n            } else {\n                img.addEventListener('load', checkAllLoaded);\n                img.addEventListener('error', checkAllLoaded);\n            }\n        });\n    </script>\n</body>\n</html>\n"
  },
  {
    "path": "cypress/test-pages/cors.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n<head>\n\t<meta charset=\"utf-8\">\n\t<meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge,chrome=1\">\n\t<title>Color Thief</title>\n    <link rel=\"stylesheet\" href=\"./screen.css\">\n</head>\n<body>\n\n    <img src=\"https://color-thief-cors-test-images.s3-us-west-1.amazonaws.com/boise-spacebar-arcade.jpeg\" crossorigin=\"anonymous\" />\n\n    <div id=\"result\"></div>\n\n    <script src=\"/dist/umd/color-thief.global.js\"></script>\n    <script>\n        const img = document.querySelector('img');\n\n        function getColorFromImage(img) {\n            const result = ColorThief.getColorSync(img);\n            document.querySelector('#result').innerText = result ? result.array().toString() : 'null';\n        }\n\n        if (img.complete) {\n            getColorFromImage(img);\n        } else {\n            img.addEventListener('load', function() {\n                getColorFromImage(img);\n            });\n        }\n    </script>\n\n\n</body>\n</html>\n"
  },
  {
    "path": "cypress/test-pages/es6-module.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n<head>\n\t<meta charset=\"utf-8\">\n\t<meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge,chrome=1\">\n\t<title>Color Thief</title>\n    <link rel=\"stylesheet\" href=\"./screen.css\">\n</head>\n<body>\n\n    <img src=\"./img/rainbow-horizontal.png\" />\n\n    <div id=\"result\"></div>\n\n    <script type=\"module\">\n        import { getColorSync } from '../../dist/index.js';\n\n        const image = document.querySelector('img');\n\n        function getColorFromImage(img) {\n            const result = getColorSync(img);\n            document.querySelector('#result').innerText = result ? result.array().toString() : 'null';\n        }\n\n        if (image.complete) {\n            getColorFromImage(image);\n        } else {\n            image.addEventListener('load', function() {\n                getColorFromImage(image);\n            });\n        }\n    </script>\n\n\n</body>\n</html>\n"
  },
  {
    "path": "cypress/test-pages/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n<head>\n\t<meta charset=\"utf-8\">\n\t<meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge,chrome=1\">\n\t<title>Color Thief</title>\n    <link rel=\"stylesheet\" href=\"./screen.css\">\n</head>\n<body>\n\n    <div id=\"example-images\"></div>\n\n    <script id='image-tpl' type='text/x-mustache'>\n        {{#.}}\n            <div class=\"image-section\" data-image=\"{{.}}\">\n                <h2>{{.}}</h2>\n                <img class=\"image\" src=\"./img/{{.}}\" />\n                <div class=\"output\"></div>\n            </div>\n        {{/.}}\n    </script>\n\n    <script id=\"color-tpl\" type=\"text/x-mustache\">\n        <div class=\"color\">\n            <h3>getColor(img)</h3>\n            <div class=\"swatches\">\n                <div class=\"swatch\" style=\"background-color: rgb({{color.0}}, {{color.1}}, {{color.2}})\"></div>\n            </div>\n            <code>\n                <div class=\"output-color\">{{colorStr}}</div>\n                <div class=\"time\">{{elapsedTime}}ms</div>\n            </code>\n        </div>\n    </script>\n\n    <script id=\"palette-tpl\" type=\"text/x-mustache\">\n        <div class=\"palette\" data-count=\"{{count}}\">\n            <h3>getPalette(img, {{count}})</h3>\n            <div class=\"swatches\">\n                {{#palette}}\n                    <div class=\"swatch\" style=\"background-color: rgb({{0}}, {{1}}, {{2}})\"></div>\n                {{/palette}}\n            </div>\n            <code>\n                <div class=\"output-palette\">{{paletteStr}}</div>\n                <div class=\"time\">{{elapsedTime}}ms</div>\n            </code>\n        </div>\n    </script>\n\n\n    <script src=\"/dist/umd/color-thief.global.js\"></script>\n    <script src=\"/node_modules/mustache/mustache.js\"></script>\n    <script src=\"index.js\"></script>\n\n</body>\n</html>\n"
  },
  {
    "path": "cypress/test-pages/index.js",
    "content": "var images = [\n    'black.png',\n    'red.png',\n    'rainbow-horizontal.png',\n    'rainbow-vertical.png',\n    'transparent.png',\n    'white.png',\n];\n\n// Render example images\nvar examplesHTML = Mustache.to_html(document.getElementById('image-tpl').innerHTML, images);\ndocument.getElementById('example-images').innerHTML = examplesHTML;\n\n// Run Color Thief functions and display results below image.\n// We also log execution time of functions for display.\nconst showColorsForImage = function(image, section) {\n    // getColorSync(img)\n    let start = Date.now();\n    let result = ColorThief.getColorSync(image);\n    let elapsedTime = Date.now() - start;\n    const rgb = result ? result.array() : null;\n    const colorHTML = Mustache.to_html(document.getElementById('color-tpl').innerHTML, {\n        color: rgb,\n        colorStr: rgb ? rgb.toString() : 'null',\n        elapsedTime\n    })\n\n    // getPaletteSync(img, { colorCount })\n    let paletteHTML = '';\n    let colorCounts = [2, 3, 5, 7, 10, 20];\n    colorCounts.forEach((count) => {\n        let start = Date.now();\n        let result = ColorThief.getPaletteSync(image, { colorCount: count });\n        let elapsedTime = Date.now() - start;\n        const rgbPalette = result ? result.map(c => c.array()) : null;\n        paletteHTML += Mustache.to_html(document.getElementById('palette-tpl').innerHTML, {\n            count,\n            palette: rgbPalette,\n            paletteStr: rgbPalette ? rgbPalette.toString() : 'null',\n            elapsedTime\n        })\n    });\n\n    const outputEl = section.querySelector('.output');\n    outputEl.innerHTML += colorHTML + paletteHTML;\n};\n\n// Once images are loaded, process them\ndocument.querySelectorAll('.image').forEach((image) => {\n    const section = image.closest('.image-section');\n    if (image.complete) {\n        showColorsForImage(image, section);\n    } else {\n        image.addEventListener('load', function() {\n            showColorsForImage(image, section);\n        });\n    }\n})\n"
  },
  {
    "path": "cypress/test-pages/screen.css",
    "content": ":root {\n    /* Colors */\n    --color: #000;\n    --bg-color: #f9f9f9;\n    --primary-color: #fc4c02;\n    --secondary-color: #f68727;\n    --muted-color: #999;\n    --code-color: var(--primary-color);\n    --code-bg-color: #fff;\n\n    /* Typography */\n    --font: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\";\n    --code-font: Menlo, Consolas, Monaco, Lucida Console, monospace;\n    --bold: 700;\n    --x-bold: 900;\n    --line-height: 1.5em;\n    --line-height-heading: 1.3em;\n\n    /* Breakpoints */\n    --sm-screen: 640px;\n}\n\n/* Base\n * *----------------------------------------------- */\n* {\n    box-sizing: border-box;\n}\n\nbody {\n    margin: 0;\n    padding: 0;\n    background: var(--bg-color);\n}\n\n/* Typography\n * *----------------------------------------------- */\n\nhtml {\n    font-size: 16px;\n    font-family: var(--font);\n    line-height: var(--line-height);\n    -webkit-font-smoothing: antialiased;\n}\n\nh1,\nh2,\nh3 {\n    font-weight: var(--x-bold);\n    line-height: var(--line-height-heading);\n    letter-spacing: -0.005em;\n}\n\nh2 {\n    margin: 0 0 0.25em 0;\n    font-size: 1.5rem;\n}\n\nh3 {\n    margin: 1em 0 0.25em 0;\n    font-size: 1.06rem;\n}\n\ncode {\n    font-family: var(--code-font);\n    overflow-wrap: break-word;\n}\n\n\n/* -- Layout ------------------------------------------------------------------ */\n\n.image-section {\n    border-bottom: 1px solid #ccc;\n    padding: 16px 16px 32px 16px;\n    margin-bottom: 32px;\n}\n\n.swatch {\n    display: inline-block;\n    background: #dddddd;\n}\n\n.color .swatch {\n    width: 6rem;\n    height: 3rem;\n}\n\n.palette .swatch {\n    width: 3rem;\n    height: 2rem;\n}\n\n.time {\n    color: var(--muted-color);\n    font-weight: normal;\n}\n"
  },
  {
    "path": "cypress.config.cjs",
    "content": "const { defineConfig } = require('cypress')\n\nmodule.exports = defineConfig({\n  e2e: {\n    // We've imported your old cypress plugins here.\n    // You may want to clean this up later by importing these.\n    setupNodeEvents(on, config) {\n      return require('./cypress/plugins/index.cjs')(on, config)\n    },\n\n    experimentalRunAllSpecs: true,\n  },\n})\n"
  },
  {
    "path": "examples/css/screen.css",
    "content": ":root {\n    /* Colors */\n    --color: #000;\n    --bg-color: #f9f9f9;\n    --primary-color: #fc4c02;\n    --secondary-color: #f68727;\n    --muted-color: #999;\n    --code-color: var(--primary-color);\n    --code-bg-color: #fff;\n\n    /* Typography */\n    --font: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\";\n    --code-font: Menlo, Consolas, Monaco, Lucida Console, monospace;\n    --bold: 700;\n    --x-bold: 900;\n    --line-height: 1.5em;\n    --line-height-heading: 1.3em;\n\n    /* Breakpoints */\n    --sm-screen: 640px;\n}\n\n/* Base\n * *----------------------------------------------- */\n* {\n    box-sizing: border-box;\n}\n\nbody {\n    margin: 0;\n    padding: 0;\n    background: var(--bg-color);\n}\n\n/* Typography\n * *----------------------------------------------- */\n\nhtml {\n    font-size: 16px;\n    font-family: var(--font);\n    line-height: var(--line-height);\n    -webkit-font-smoothing: antialiased;\n}\n\nh1,\nh2,\nh3 {\n    font-weight: var(--x-bold);\n    line-height: var(--line-height-heading);\n    letter-spacing: -0.005em;\n}\n\nh2 {\n    margin: 0 0 0.25em 0;\n    font-size: 1.5rem;\n}\n\nh3 {\n    margin: 1em 0 0.25em 0;\n    font-size: 1.06rem;\n}\n\ncode {\n    font-family: var(--code-font);\n    overflow-wrap: break-word;\n}\n\n\n/* -- Layout ------------------------------------------------------------------ */\n\n.image-section {\n    border-bottom: 1px solid #ccc;\n    padding: 16px 16px 32px 16px;\n    margin-bottom: 32px;\n}\n\n.swatch {\n    display: inline-block;\n    background: #dddddd;\n    border-radius: 8px;\n}\n\n.color .swatch {\n    width: 6rem;\n    height: 3rem;\n}\n\n.palette .swatch {\n    width: 3rem;\n    height: 2rem;\n}\n\n.time {\n    color: var(--muted-color);\n    font-weight: normal;\n}\n"
  },
  {
    "path": "examples/js/demo.js",
    "content": "var colorThief = new ColorThief();\n\nvar images = [\n    'image-1.jpg',\n    'image-2.jpg',\n    'image-3.jpg',\n];\n\n// Render example images\nvar examplesHTML = Mustache.to_html(document.getElementById('image-tpl').innerHTML, images);\ndocument.getElementById('example-images').innerHTML = examplesHTML;\n\n// Once images are loaded, process them\ndocument.querySelectorAll('.image').forEach((image) => {\n    const section = image.closest('.image-section');\n    if (image.complete) {\n        showColorsForImage(image, section);\n    } else {\n        image.addEventListener('load', function() {\n            showColorsForImage(image, section);\n        });\n    }\n})\n\n// Run Color Thief functions and display results below image.\n// We also log execution time of functions for display.\nconst showColorsForImage = function(image, section) {\n    let start = Date.now();\n\n    // 🎨🔓\n    let result = colorThief.getColor(image);\n\n    let elapsedTime = Date.now() - start;\n    const colorHTML = Mustache.to_html(document.getElementById('color-tpl').innerHTML, {\n        color: result,\n        colorStr: result.toString(),\n        elapsedTime\n    })\n\n    // getPalette(img)\n    let paletteHTML = '';\n    let colorCounts = [3, 9];\n    colorCounts.forEach((count) => {\n        let start = Date.now();\n\n        // 🎨🔓\n        let result = colorThief.getPalette(image, count);\n\n        let elapsedTime = Date.now() - start;\n        paletteHTML += Mustache.to_html(document.getElementById('palette-tpl').innerHTML, {\n            count,\n            palette: result,\n            paletteStr: result.toString(),\n            elapsedTime\n        })\n    });\n\n    const outputEl = section.querySelector('.output');\n    outputEl.innerHTML += colorHTML + paletteHTML;\n};\n"
  },
  {
    "path": "index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"utf-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n    <title>Color Thief v3 — Examples</title>\n    <style>\n        /* ------------------------------------------------------------------ */\n        /* Variables                                                           */\n        /* ------------------------------------------------------------------ */\n        :root {\n            --sans: 'Helvetica Neue', Helvetica, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;\n            --mono: ui-monospace, 'SF Mono', 'Cascadia Code', 'Fira Code', Menlo, Consolas, 'Liberation Mono', monospace;\n            --text: #111;\n            --text-2: #444;\n            --text-3: #888;\n            --bg: #fff;\n            --surface: #f6f6f6;\n            --border: #e0e0e0;\n            --radius: 8px;\n            --container: 860px;\n        }\n\n        /* ------------------------------------------------------------------ */\n        /* Reset & base                                                        */\n        /* ------------------------------------------------------------------ */\n        *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n\n        body {\n            font-family: var(--sans);\n            color: var(--text);\n            background: var(--bg);\n            line-height: 1.6;\n            -webkit-font-smoothing: antialiased;\n            -moz-osx-font-smoothing: grayscale;\n        }\n\n        img { display: block; max-width: 100%; }\n\n        /* ------------------------------------------------------------------ */\n        /* Typography                                                          */\n        /* ------------------------------------------------------------------ */\n        h1 {\n            font-size: 2.5rem;\n            font-weight: 700;\n            letter-spacing: -0.03em;\n            line-height: 1.15;\n        }\n\n        h2 {\n            font-size: 1.25rem;\n            font-weight: 600;\n            letter-spacing: -0.015em;\n            font-family: var(--mono);\n        }\n\n        h3 {\n            font-size: 0.875rem;\n            font-weight: 600;\n            text-transform: uppercase;\n            letter-spacing: 0.05em;\n            color: var(--text-3);\n        }\n\n        p { color: var(--text-2); }\n\n        code, pre { font-family: var(--mono); }\n\n        /* ------------------------------------------------------------------ */\n        /* Layout                                                              */\n        /* ------------------------------------------------------------------ */\n        .container {\n            max-width: var(--container);\n            margin: 0 auto;\n            padding: 0 24px;\n        }\n\n        /* ------------------------------------------------------------------ */\n        /* Header                                                              */\n        /* ------------------------------------------------------------------ */\n        .header {\n            padding: 80px 0 60px;\n            border-bottom: 1px solid var(--border);\n        }\n\n        .header p {\n            margin-top: 8px;\n            font-size: 1.1rem;\n        }\n\n        .version {\n            display: inline-block;\n            font-family: var(--mono);\n            font-size: 0.5em;\n            font-weight: 500;\n            vertical-align: super;\n            color: var(--text-3);\n            margin-left: 2px;\n        }\n\n        /* ------------------------------------------------------------------ */\n        /* Sections                                                            */\n        /* ------------------------------------------------------------------ */\n        .section {\n            padding: 56px 0;\n            border-bottom: 1px solid var(--border);\n        }\n\n        .section:last-child { border-bottom: none; }\n\n        .section-num {\n            display: inline-block;\n            font-family: var(--mono);\n            font-size: 0.75rem;\n            font-weight: 500;\n            color: var(--text-3);\n            background: var(--surface);\n            padding: 2px 10px;\n            border-radius: 99px;\n            margin-bottom: 12px;\n        }\n\n        .section h2 { margin-bottom: 6px; }\n\n        .section > p {\n            margin-bottom: 20px;\n            max-width: 600px;\n        }\n\n        /* ------------------------------------------------------------------ */\n        /* Code blocks                                                         */\n        /* ------------------------------------------------------------------ */\n        .code-block {\n            background: var(--surface);\n            border-left: 3px solid var(--border);\n            border-radius: 0 var(--radius) var(--radius) 0;\n            padding: 16px 20px;\n            margin-bottom: 28px;\n            overflow-x: auto;\n            font-size: 0.85rem;\n            line-height: 1.65;\n            color: var(--text-2);\n        }\n\n        .code-block code {\n            white-space: pre;\n        }\n\n        /* ------------------------------------------------------------------ */\n        /* Output areas                                                        */\n        /* ------------------------------------------------------------------ */\n        .output {\n            opacity: 0;\n            transform: translateY(6px);\n            transition: opacity 0.4s ease, transform 0.4s ease;\n        }\n\n        .output.visible {\n            opacity: 1;\n            transform: translateY(0);\n        }\n\n        /* ------------------------------------------------------------------ */\n        /* Demo images                                                         */\n        /* ------------------------------------------------------------------ */\n        .demo-img {\n            width: 100%;\n            max-width: 240px;\n            border-radius: var(--radius);\n            object-fit: cover;\n        }\n\n        .demo-img-sm {\n            width: 100%;\n            max-width: 180px;\n            border-radius: var(--radius);\n            object-fit: cover;\n        }\n\n        .source-img {\n            width: 100%;\n            max-width: 240px;\n            border-radius: var(--radius);\n            margin-bottom: 20px;\n        }\n\n        /* ------------------------------------------------------------------ */\n        /* Swatches                                                            */\n        /* ------------------------------------------------------------------ */\n        .swatch {\n            display: inline-block;\n            border-radius: 6px;\n            position: relative;\n            cursor: default;\n            transition: transform 0.15s ease;\n        }\n\n        .swatch:hover { transform: scale(1.08); }\n\n        .swatch-lg {\n            width: 64px;\n            height: 40px;\n        }\n\n        .swatch-md {\n            width: 48px;\n            height: 32px;\n        }\n\n        .swatch-sm {\n            width: 36px;\n            height: 24px;\n        }\n\n        .swatch[data-hex]::after {\n            content: attr(data-hex);\n            position: absolute;\n            bottom: -20px;\n            left: 50%;\n            transform: translateX(-50%);\n            font-family: var(--mono);\n            font-size: 0.65rem;\n            color: var(--text-3);\n            white-space: nowrap;\n            opacity: 0;\n            transition: opacity 0.15s;\n            pointer-events: none;\n        }\n\n        .swatch:hover[data-hex]::after { opacity: 1; }\n\n        .swatch-row {\n            display: flex;\n            flex-wrap: wrap;\n            gap: 8px;\n            padding-bottom: 20px;\n        }\n\n        /* ------------------------------------------------------------------ */\n        /* Dominant color cards                                                 */\n        /* ------------------------------------------------------------------ */\n        .dominant-grid {\n            display: grid;\n            grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));\n            gap: 24px;\n        }\n\n        .dominant-card { display: flex; flex-direction: column; gap: 12px; }\n\n        .dominant-result {\n            display: flex;\n            align-items: center;\n            gap: 12px;\n        }\n\n        .dominant-meta {\n            font-family: var(--mono);\n            font-size: 0.8rem;\n            color: var(--text-2);\n            line-height: 1.7;\n        }\n\n        .dominant-meta .hex { font-weight: 600; color: var(--text); }\n\n        .timing {\n            font-family: var(--mono);\n            font-size: 0.75rem;\n            color: var(--text-3);\n        }\n\n        /* ------------------------------------------------------------------ */\n        /* Property table                                                      */\n        /* ------------------------------------------------------------------ */\n        .prop-table {\n            width: 100%;\n            max-width: 560px;\n            border-collapse: collapse;\n            font-size: 0.85rem;\n        }\n\n        .prop-table th {\n            text-align: left;\n            font-weight: 500;\n            font-family: var(--mono);\n            color: var(--text-3);\n            padding: 8px 16px 8px 0;\n            border-bottom: 1px solid var(--border);\n            white-space: nowrap;\n            font-size: 0.8rem;\n            text-transform: uppercase;\n            letter-spacing: 0.04em;\n        }\n\n        .prop-table td {\n            padding: 8px 0;\n            border-bottom: 1px solid #f0f0f0;\n            vertical-align: middle;\n        }\n\n        .prop-table td:first-child {\n            font-family: var(--mono);\n            font-weight: 500;\n            padding-right: 24px;\n            white-space: nowrap;\n            color: var(--text-2);\n        }\n\n        .prop-table td:last-child {\n            font-family: var(--mono);\n            font-size: 0.85rem;\n        }\n\n        .prop-table tr:last-child td { border-bottom: none; }\n\n        .prop-swatch {\n            display: inline-block;\n            width: 16px;\n            height: 16px;\n            border-radius: 4px;\n            vertical-align: middle;\n            margin-right: 6px;\n        }\n\n        /* ------------------------------------------------------------------ */\n        /* Swatch role cards                                                   */\n        /* ------------------------------------------------------------------ */\n        .swatch-cards {\n            display: grid;\n            grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));\n            gap: 12px;\n        }\n\n        .swatch-card {\n            border-radius: var(--radius);\n            padding: 20px 16px;\n            min-height: 100px;\n            display: flex;\n            flex-direction: column;\n            justify-content: space-between;\n        }\n\n        .swatch-card-empty {\n            background: var(--surface);\n            border: 2px dashed var(--border);\n            display: flex;\n            align-items: center;\n            justify-content: center;\n        }\n\n        .swatch-card .role {\n            font-family: var(--mono);\n            font-size: 0.7rem;\n            font-weight: 600;\n            text-transform: uppercase;\n            letter-spacing: 0.04em;\n        }\n\n        .swatch-card .hex-label {\n            font-family: var(--mono);\n            font-size: 0.8rem;\n            margin-top: 8px;\n        }\n\n        .swatch-card-empty .role {\n            font-size: 0.7rem;\n            color: var(--text-3);\n        }\n\n        /* ------------------------------------------------------------------ */\n        /* Comparison layouts                                                   */\n        /* ------------------------------------------------------------------ */\n        .comparison {\n            display: grid;\n            grid-template-columns: 1fr 1fr;\n            gap: 32px;\n        }\n\n        @media (max-width: 600px) {\n            .comparison { grid-template-columns: 1fr; }\n        }\n\n        .comparison-col h3 { margin-bottom: 12px; }\n\n        /* ------------------------------------------------------------------ */\n        /* Quality rows                                                        */\n        /* ------------------------------------------------------------------ */\n        .quality-row {\n            display: flex;\n            align-items: center;\n            gap: 16px;\n            padding: 12px 0;\n        }\n\n        .quality-row:not(:last-child) {\n            border-bottom: 1px solid #f0f0f0;\n        }\n\n        .quality-label {\n            font-family: var(--mono);\n            font-size: 0.8rem;\n            font-weight: 500;\n            min-width: 100px;\n            color: var(--text-2);\n        }\n\n        .quality-swatches { display: flex; gap: 6px; flex: 1; flex-wrap: wrap; }\n\n        /* ------------------------------------------------------------------ */\n        /* Async comparison                                                    */\n        /* ------------------------------------------------------------------ */\n        .async-row {\n            display: flex;\n            align-items: center;\n            gap: 16px;\n            padding: 14px 0;\n            border-bottom: 1px solid #f0f0f0;\n        }\n\n        .async-row:last-child { border-bottom: none; }\n\n        .async-label {\n            font-family: var(--mono);\n            font-size: 0.8rem;\n            font-weight: 500;\n            min-width: 140px;\n            color: var(--text-2);\n        }\n\n        .async-swatches { display: flex; gap: 6px; flex: 1; flex-wrap: wrap; }\n\n        /* ------------------------------------------------------------------ */\n        /* Progressive                                                         */\n        /* ------------------------------------------------------------------ */\n        .progress-track {\n            height: 6px;\n            background: var(--surface);\n            border-radius: 99px;\n            overflow: hidden;\n            margin-bottom: 16px;\n        }\n\n        .progress-fill {\n            height: 100%;\n            width: 0;\n            border-radius: 99px;\n            background: var(--text);\n            transition: width 0.3s ease, background 0.3s ease;\n        }\n\n        .progress-label {\n            font-family: var(--mono);\n            font-size: 0.8rem;\n            color: var(--text-3);\n            margin-bottom: 6px;\n        }\n\n        .progressive-stage {\n            padding: 12px 0;\n            border-bottom: 1px solid #f0f0f0;\n        }\n\n        .progressive-stage:last-child { border-bottom: none; }\n\n        .progressive-stage .stage-header {\n            display: flex;\n            align-items: center;\n            gap: 12px;\n            margin-bottom: 8px;\n        }\n\n        .stage-badge {\n            font-family: var(--mono);\n            font-size: 0.7rem;\n            font-weight: 500;\n            background: var(--surface);\n            padding: 2px 8px;\n            border-radius: 99px;\n            color: var(--text-3);\n        }\n\n        .stage-badge.final {\n            background: var(--text);\n            color: #fff;\n        }\n\n        /* ------------------------------------------------------------------ */\n        /* Error box                                                           */\n        /* ------------------------------------------------------------------ */\n        .error-box {\n            font-family: var(--mono);\n            font-size: 0.85rem;\n            background: #fef2f2;\n            border: 1px solid #fecaca;\n            color: #991b1b;\n            padding: 16px 20px;\n            border-radius: var(--radius);\n            line-height: 1.6;\n        }\n\n        .error-box strong {\n            display: block;\n            margin-bottom: 4px;\n        }\n\n        /* ------------------------------------------------------------------ */\n        /* Create-color demo                                                   */\n        /* ------------------------------------------------------------------ */\n        .color-preview {\n            display: flex;\n            align-items: center;\n            gap: 16px;\n            margin-bottom: 20px;\n        }\n\n        .color-preview-swatch {\n            width: 80px;\n            height: 80px;\n            border-radius: var(--radius);\n            display: flex;\n            align-items: center;\n            justify-content: center;\n            font-family: var(--mono);\n            font-size: 0.75rem;\n            font-weight: 600;\n        }\n\n        .color-preview-hex {\n            font-family: var(--mono);\n            font-size: 1.5rem;\n            font-weight: 600;\n        }\n\n        /* ------------------------------------------------------------------ */\n        /* Footer                                                              */\n        /* ------------------------------------------------------------------ */\n        .footer {\n            padding: 40px 0 60px;\n            text-align: center;\n        }\n\n        .footer a {\n            color: var(--text-3);\n            text-decoration: none;\n            font-family: var(--mono);\n            font-size: 0.85rem;\n            border-bottom: 1px solid var(--border);\n            padding-bottom: 1px;\n            transition: color 0.15s, border-color 0.15s;\n        }\n\n        .footer a:hover { color: var(--text); border-color: var(--text); }\n\n        /* ------------------------------------------------------------------ */\n        /* Responsive                                                          */\n        /* ------------------------------------------------------------------ */\n        @media (max-width: 600px) {\n            h1 { font-size: 1.75rem; }\n            .header { padding: 48px 0 40px; }\n            .section { padding: 40px 0; }\n            .dominant-grid { grid-template-columns: 1fr; }\n            .swatch-cards { grid-template-columns: repeat(2, 1fr); }\n        }\n\n        /* ------------------------------------------------------------------ */\n        /* Observe / video glow                                                */\n        /* ------------------------------------------------------------------ */\n        .video-glow-wrap {\n            position: relative;\n            border-radius: var(--radius);\n            max-width: 320px;\n            margin-bottom: 36px;\n        }\n\n        .video-glow-wrap::before {\n            content: '';\n            position: absolute;\n            left: -20%;\n            top: -20%;\n            width: 110%;\n            height: 110%;\n            inset: 4px;\n            border-radius: inherit;\n            background: var(--glow-color, transparent);\n            filter: blur(36px) saturate(1.8);\n            opacity: 1;\n            z-index: 0;\n            transition: background 1s ease;\n        }\n\n        .video-glow-wrap video {\n            position: relative;\n            display: block;\n            width: 100%;\n            border-radius: inherit;\n            z-index: 1;\n            cursor: pointer;\n        }\n\n        .video-play-btn {\n            position: absolute;\n            inset: 0;\n            z-index: 2;\n            display: flex;\n            align-items: center;\n            justify-content: center;\n            background: none;\n            border: none;\n            cursor: pointer;\n            transition: opacity 0.25s ease;\n        }\n\n        .video-play-btn.hidden {\n            opacity: 0;\n            pointer-events: none;\n        }\n\n        .observe-dominant {\n            display: flex;\n            align-items: center;\n            gap: 12px;\n            margin-bottom: 16px;\n            min-height: 48px;\n        }\n\n        .observe-dominant-swatch {\n            width: 48px;\n            height: 48px;\n            border-radius: var(--radius);\n            flex-shrink: 0;\n        }\n\n        .observe-dominant-meta {\n            font-family: var(--mono);\n            font-size: 0.85rem;\n            line-height: 1.5;\n        }\n\n        .observe-label {\n            font-family: var(--mono);\n            font-size: 0.7rem;\n            font-weight: 600;\n            text-transform: uppercase;\n            letter-spacing: 0.05em;\n            color: var(--text-3);\n            margin-bottom: 6px;\n        }\n    </style>\n</head>\n<body>\n\n    <!-- ================================================================== -->\n    <!-- Header                                                              -->\n    <!-- ================================================================== -->\n    <header class=\"header\">\n        <div class=\"container\">\n            <h1>Color Thief <span class=\"version\">v3</span></h1>\n            <p>Extract dominant colors and palettes from images. Every example on this page runs live.</p>\n        </div>\n    </header>\n\n    <main class=\"container\">\n\n        <!-- ============================================================== -->\n        <!-- 01. Loading the Library                                        -->\n        <!-- ============================================================== -->\n        <section class=\"section\">\n            <span class=\"section-num\">01</span>\n            <h2>Loading the Library</h2>\n            <p>Color Thief works in browsers and Node.js. Pick the method that fits your setup.</p>\n\n            <h3>Install</h3>\n            <div class=\"code-block\"><code>npm install colorthief</code></div>\n\n            <h3>ESM (bundlers &amp; Node.js)</h3>\n            <div class=\"code-block\"><code>import { getColorSync, getPaletteSync } from 'colorthief';</code></div>\n\n            <h3>CommonJS (Node.js)</h3>\n            <div class=\"code-block\"><code>const { getColor, getPalette } = require('colorthief');</code></div>\n\n            <h3>Script tag (no build step)</h3>\n            <div class=\"code-block\"><code>&lt;script src=\"https://unpkg.com/colorthief@3/dist/umd/color-thief.global.js\"&gt;&lt;/script&gt;\n&lt;script&gt;\n    const color = ColorThief.getColorSync(img);\n&lt;/script&gt;</code></div>\n\n            <h3>ES module in the browser (no bundler)</h3>\n            <div class=\"code-block\"><code>&lt;script type=\"module\"&gt;\n    import { getColorSync } from 'https://unpkg.com/colorthief@3/dist/index.js';\n&lt;/script&gt;</code></div>\n        </section>\n\n        <!-- ============================================================== -->\n        <!-- 02. getColorSync                                                -->\n        <!-- ============================================================== -->\n        <section class=\"section\">\n            <span class=\"section-num\">02</span>\n            <h2>getColorSync()</h2>\n            <p>The simplest way to use Color Thief. Extract the single dominant color from an image, synchronously.</p>\n\n            <div class=\"code-block\"><code>const color = getColorSync(img);\ncolor.hex();       // '#e84393'\ncolor.textColor;   // '#000000'</code></div>\n\n            <div class=\"output\" id=\"out-dominant\">\n                <div class=\"dominant-grid\">\n                    <div class=\"dominant-card\">\n                        <img class=\"demo-img\" id=\"img1\" src=\"examples/img/image-1.jpg\" alt=\"Example 1\">\n                        <div class=\"dominant-result\" id=\"dom-result-1\"></div>\n                    </div>\n                    <div class=\"dominant-card\">\n                        <img class=\"demo-img\" id=\"img2\" src=\"examples/img/image-2.jpg\" alt=\"Example 2\">\n                        <div class=\"dominant-result\" id=\"dom-result-2\"></div>\n                    </div>\n                    <div class=\"dominant-card\">\n                        <img class=\"demo-img\" id=\"img3\" src=\"examples/img/image-3.jpg\" alt=\"Example 3\">\n                        <div class=\"dominant-result\" id=\"dom-result-3\"></div>\n                    </div>\n                </div>\n            </div>\n        </section>\n\n        <!-- ============================================================== -->\n        <!-- 03. getPaletteSync                                              -->\n        <!-- ============================================================== -->\n        <section class=\"section\">\n            <span class=\"section-num\">03</span>\n            <h2>getPaletteSync()</h2>\n            <p>Extract a multi-color palette. Each color in the palette is a full Color object.</p>\n\n            <div class=\"code-block\"><code>const palette = getPaletteSync(img, { colorCount: 8 });\npalette.forEach(c => console.log(c.hex()));</code></div>\n\n            <div class=\"output\" id=\"out-palette\">\n                <img class=\"source-img\" src=\"examples/img/image-1.jpg\" alt=\"Source image\">\n                <div class=\"swatch-row\" id=\"palette-swatches\"></div>\n                <div class=\"timing\" id=\"palette-timing\"></div>\n            </div>\n        </section>\n\n        <!-- ============================================================== -->\n        <!-- 04. Color Object                                                -->\n        <!-- ============================================================== -->\n        <section class=\"section\">\n            <span class=\"section-num\">04</span>\n            <h2>Color Object</h2>\n            <p>Every extracted color is a rich object with format conversions, accessibility metadata, and contrast ratios.</p>\n\n            <div class=\"code-block\"><code>const color = getColorSync(img);\n\ncolor.rgb()        // { r, g, b }\ncolor.hex()        // '#rrggbb'\ncolor.hsl()        // { h, s, l }\ncolor.oklch()      // { l, c, h }\ncolor.css()        // 'rgb(255, 128, 0)' — also 'hsl' and 'oklch'\ncolor.array()      // [r, g, b]\ncolor.toString()   // '#rrggbb' — works in template literals\ncolor.textColor    // '#ffffff' or '#000000'\ncolor.isDark       // true/false\ncolor.isLight      // true/false\ncolor.contrast     // { white, black, foreground }\ncolor.population   // raw pixel count\ncolor.proportion   // 0–1 share of total</code></div>\n\n            <div class=\"output\" id=\"out-color-obj\">\n                <img class=\"source-img\" src=\"examples/img/image-1.jpg\" alt=\"Source image\">\n                <div class=\"color-preview\" id=\"color-preview\"></div>\n                <table class=\"prop-table\" id=\"prop-table\"></table>\n            </div>\n        </section>\n\n        <!-- ============================================================== -->\n        <!-- 05. getSwatchesSync                                             -->\n        <!-- ============================================================== -->\n        <section class=\"section\">\n            <span class=\"section-num\">05</span>\n            <h2>getSwatchesSync()</h2>\n            <p>Classify palette colors into six semantic roles: Vibrant, Muted, DarkVibrant, DarkMuted, LightVibrant, LightMuted. Each swatch includes text color recommendations.</p>\n\n            <div class=\"code-block\"><code>const swatches = getSwatchesSync(img);\nswatches.Vibrant?.color.hex();          // '#e84393'\nswatches.DarkMuted?.titleTextColor.hex(); // '#ffffff'</code></div>\n\n            <div class=\"output\" id=\"out-swatches\">\n                <img class=\"source-img\" src=\"examples/img/image-2.jpg\" alt=\"Source image\">\n                <div class=\"swatch-cards\" id=\"swatch-cards\"></div>\n            </div>\n        </section>\n\n        <!-- ============================================================== -->\n        <!-- 06. OKLCH vs RGB                                                -->\n        <!-- ============================================================== -->\n        <section class=\"section\">\n            <span class=\"section-num\">06</span>\n            <h2>OKLCH vs RGB Quantization</h2>\n            <p>OKLCH quantization produces more perceptually uniform palettes. Colors that \"feel\" evenly spaced to the human eye.</p>\n\n            <div class=\"code-block\"><code>// Default — quantize in sRGB\nconst rgb = getPaletteSync(img, { colorCount: 8 });\n\n// Perceptual — quantize in OKLCH\nconst oklch = getPaletteSync(img, { colorCount: 8, colorSpace: 'oklch' });</code></div>\n\n            <div class=\"output\" id=\"out-oklch\">\n                <img class=\"source-img\" src=\"examples/img/image-3.jpg\" alt=\"Source image\">\n                <div class=\"comparison\">\n                    <div class=\"comparison-col\">\n                        <h3>RGB (default)</h3>\n                        <div class=\"swatch-row\" id=\"oklch-rgb\"></div>\n                    </div>\n                    <div class=\"comparison-col\">\n                        <h3>OKLCH</h3>\n                        <div class=\"swatch-row\" id=\"oklch-oklch\"></div>\n                    </div>\n                </div>\n            </div>\n        </section>\n\n        <!-- ============================================================== -->\n        <!-- 07. Quality comparison                                          -->\n        <!-- ============================================================== -->\n        <section class=\"section\">\n            <span class=\"section-num\">07</span>\n            <h2>Quality Settings</h2>\n            <p>The <code>quality</code> option controls how many pixels are sampled. Lower values sample more pixels (slower, more accurate). Default is 10.</p>\n\n            <div class=\"code-block\"><code>getPaletteSync(img, { quality: 1 });   // Every pixel\ngetPaletteSync(img, { quality: 10 });  // Every 10th pixel (default)\ngetPaletteSync(img, { quality: 50 });  // Every 50th pixel</code></div>\n\n            <div class=\"output\" id=\"out-quality\">\n                <img class=\"source-img\" src=\"examples/img/image-1.jpg\" alt=\"Source image\">\n            </div>\n        </section>\n\n        <!-- ============================================================== -->\n        <!-- 08. Async API & Workers                                         -->\n        <!-- ============================================================== -->\n        <section class=\"section\">\n            <span class=\"section-num\">08</span>\n            <h2>Async API &amp; Web Workers</h2>\n            <p>The async API works on both browser and Node.js. With <code>worker: true</code>, quantization runs off the main thread.</p>\n\n            <div class=\"code-block\"><code>// Async (works in browser and Node.js)\nconst palette = await getPalette(img, { colorCount: 6 });\n\n// Offload to Web Worker (browser only)\nconst palette = await getPalette(img, { colorCount: 6, worker: true });</code></div>\n\n            <div class=\"output\" id=\"out-async\">\n                <img class=\"source-img\" src=\"examples/img/image-1.jpg\" alt=\"Source image\">\n            </div>\n        </section>\n\n        <!-- ============================================================== -->\n        <!-- 09. Progressive extraction                                      -->\n        <!-- ============================================================== -->\n        <section class=\"section\">\n            <span class=\"section-num\">09</span>\n            <h2>getPaletteProgressive()</h2>\n            <p>Progressively extract a palette in 3 passes. Show a rough result instantly, then refine it. Useful for large images where full extraction takes time.</p>\n\n            <div class=\"code-block\"><code>for await (const { palette, progress, done } of getPaletteProgressive(img)) {\n    updateUI(palette, progress);\n    // progress: 0.06 → 0.25 → 1.0\n}</code></div>\n\n            <div class=\"output\" id=\"out-progressive\">\n                <img class=\"source-img\" src=\"examples/img/image-2.jpg\" alt=\"Source image\">\n                <div class=\"progress-track\">\n                    <div class=\"progress-fill\" id=\"prog-fill\"></div>\n                </div>\n                <div id=\"prog-stages\"></div>\n            </div>\n        </section>\n\n        <!-- ============================================================== -->\n        <!-- 10. AbortController                                             -->\n        <!-- ============================================================== -->\n        <section class=\"section\">\n            <span class=\"section-num\">10</span>\n            <h2>AbortController</h2>\n            <p>Cancel in-flight extractions with a standard AbortSignal. Useful when the user navigates away before extraction completes.</p>\n\n            <div class=\"code-block\"><code>const controller = new AbortController();\ncontroller.abort(); // cancel immediately\n\ntry {\n    await getColor(img, { signal: controller.signal });\n} catch (e) {\n    console.log(e.name); // 'AbortError'\n}</code></div>\n\n            <div class=\"output\" id=\"out-abort\">\n                <img class=\"source-img\" src=\"examples/img/image-1.jpg\" alt=\"Source image\">\n            </div>\n        </section>\n\n        <!-- ============================================================== -->\n        <!-- 11. createColor                                                 -->\n        <!-- ============================================================== -->\n        <section class=\"section\">\n            <span class=\"section-num\">11</span>\n            <h2>createColor()</h2>\n            <p>Build a Color object manually from RGB values. Useful for creating colors programmatically or working with known values.</p>\n\n            <div class=\"code-block\"><code>const color = createColor(232, 67, 147, 1);\ncolor.hex();       // '#e84393'\ncolor.isDark;      // false\ncolor.textColor;   // '#000000'\n`${color}`;        // '#e84393' — toString() returns hex</code></div>\n\n            <div class=\"output\" id=\"out-create\">\n                <div class=\"color-preview\" id=\"create-preview\"></div>\n                <table class=\"prop-table\" id=\"create-table\"></table>\n            </div>\n        </section>\n\n        <!-- ============================================================== -->\n        <!-- 12. observe — Live Extraction                                   -->\n        <!-- ============================================================== -->\n        <section class=\"section\">\n            <span class=\"section-num\">12</span>\n            <h2>observe()</h2>\n            <p>Reactively watch a source and get palette updates whenever it changes. Works with <code>&lt;video&gt;</code>, <code>&lt;canvas&gt;</code>, and <code>&lt;img&gt;</code> elements. For video, extraction runs on each animation frame (throttled) while playing, and on seek. For canvas, it polls via rAF. For images, it uses a MutationObserver to detect <code>src</code> changes.</p>\n\n            <div class=\"code-block\"><code>const controller = observe(videoElement, {\n    throttle: 200,    // ms between updates\n    colorCount: 5,\n    onChange(palette) {\n        const dominant = palette[0];\n        document.body.style.background = dominant.css();\n    },\n});\n\ncontroller.stop(); // clean up</code></div>\n\n            <div class=\"output\" id=\"out-observe\" style=\"margin-top:48px\">\n                <div>\n                    <div class=\"video-glow-wrap\" id=\"video-glow-wrap\">\n                        <video id=\"observe-video\" src=\"examples/video/colors.mp4\" muted loop playsinline></video>\n                        <button class=\"video-play-btn\" id=\"video-play-btn\" aria-label=\"Play video\">\n                            <svg width=\"48\" height=\"48\" viewBox=\"0 0 48 48\" fill=\"none\"><circle cx=\"24\" cy=\"24\" r=\"24\" fill=\"rgba(0,0,0,0.5)\"/><polygon points=\"19,14 19,34 36,24\" fill=\"#fff\"/></svg>\n                        </button>\n                    </div>\n                </div>\n\n                <div>\n                    <div class=\"observe-label\">Dominant</div>\n                    <div class=\"observe-dominant\" id=\"observe-dominant\"></div>\n                </div>\n\n            </div>\n        </section>\n\n    </main>\n\n    <footer class=\"footer\">\n        <a href=\"https://github.com/lokesh/color-thief\">github.com/lokesh/color-thief</a>\n    </footer>\n\n    <!-- ================================================================== -->\n    <!-- Library + Demo Script                                               -->\n    <!-- ================================================================== -->\n    <script src=\"dist/umd/color-thief.global.js\"></script>\n    <script>\n    (async () => {\n        const {\n            getColorSync,\n            getPaletteSync,\n            getSwatchesSync,\n            getColor,\n            getPalette,\n            getPaletteProgressive,\n            createColor,\n            observe,\n        } = ColorThief;\n\n        // ------------------------------------------------------------------\n        // Helpers\n        // ------------------------------------------------------------------\n\n        function waitForImage(img) {\n            return new Promise((resolve, reject) => {\n                if (img.complete && img.naturalWidth) resolve(img);\n                else {\n                    img.addEventListener('load', () => resolve(img));\n                    img.addEventListener('error', reject);\n                }\n            });\n        }\n\n        function timed(fn) {\n            const t0 = performance.now();\n            const result = fn();\n            return { result, ms: (performance.now() - t0).toFixed(1) };\n        }\n\n        async function timedAsync(fn) {\n            const t0 = performance.now();\n            const result = await fn();\n            return { result, ms: (performance.now() - t0).toFixed(1) };\n        }\n\n        function show(id) {\n            document.getElementById(id).classList.add('visible');\n        }\n\n        function swatchHTML(color, size = 'md') {\n            return `<div class=\"swatch swatch-${size}\" style=\"background:${color.hex()}\" data-hex=\"${color.hex()}\"></div>`;\n        }\n\n        function renderColorTable(color, tableId) {\n            const { r, g, b } = color.rgb();\n            const hsl = color.hsl();\n            const oklch = color.oklch();\n            const rows = [\n                ['.rgb()', `{ r: ${r}, g: ${g}, b: ${b} }`],\n                ['.hex()', color.hex()],\n                ['.hsl()', `{ h: ${hsl.h}, s: ${hsl.s}, l: ${hsl.l} }`],\n                ['.oklch()', `{ l: ${oklch.l.toFixed(3)}, c: ${oklch.c.toFixed(3)}, h: ${oklch.h.toFixed(1)} }`],\n                [\".css('rgb')\", color.css('rgb')],\n                [\".css('hsl')\", color.css('hsl')],\n                [\".css('oklch')\", color.css('oklch')],\n                ['.array()', `[${color.array().join(', ')}]`],\n                ['.toString()', `\"${color.toString()}\"`],\n                ['.textColor', `<span class=\"prop-swatch\" style=\"background:${color.textColor}\"></span>${color.textColor}`],\n                ['.isDark', String(color.isDark)],\n                ['.isLight', String(color.isLight)],\n                ['.population', String(color.population)],\n                ['.proportion', color.proportion.toFixed(4)],\n                ['.contrast.white', color.contrast.white.toFixed(2)],\n                ['.contrast.black', color.contrast.black.toFixed(2)],\n                ['.contrast.foreground', `<span class=\"prop-swatch\" style=\"background:${color.contrast.foreground.hex()}\"></span>${color.contrast.foreground.hex()}`],\n            ];\n\n            document.getElementById(tableId).innerHTML =\n                '<thead><tr><th>Property</th><th>Value</th></tr></thead><tbody>' +\n                rows.map(([prop, val]) => `<tr><td>${prop}</td><td>${val}</td></tr>`).join('') +\n                '</tbody>';\n        }\n\n        // ------------------------------------------------------------------\n        // Wait for images\n        // ------------------------------------------------------------------\n        const img1 = document.getElementById('img1');\n        const img2 = document.getElementById('img2');\n        const img3 = document.getElementById('img3');\n\n        await Promise.all([waitForImage(img1), waitForImage(img2), waitForImage(img3)]);\n\n        // ------------------------------------------------------------------\n        // 02. getColorSync — Dominant Color\n        // ------------------------------------------------------------------\n        {\n            [\n                [img1, 'dom-result-1'],\n                [img2, 'dom-result-2'],\n                [img3, 'dom-result-3'],\n            ].forEach(([img, id]) => {\n                const { result: color, ms } = timed(() => getColorSync(img));\n                if (!color) return;\n                const { r, g, b } = color.rgb();\n                document.getElementById(id).innerHTML =\n                    swatchHTML(color, 'lg') +\n                    `<div class=\"dominant-meta\">\n                        <span class=\"hex\">${color.hex()}</span><br>\n                        rgb(${r}, ${g}, ${b})<br>\n                        <span class=\"timing\">${ms}ms</span>\n                    </div>`;\n            });\n            show('out-dominant');\n        }\n\n        // ------------------------------------------------------------------\n        // 03. getPaletteSync — Palette\n        // ------------------------------------------------------------------\n        {\n            const { result: palette, ms } = timed(() => getPaletteSync(img1, { colorCount: 8 }));\n            if (palette) {\n                document.getElementById('palette-swatches').innerHTML =\n                    palette.map(c => swatchHTML(c, 'lg')).join('');\n                document.getElementById('palette-timing').textContent = `${ms}ms`;\n            }\n            show('out-palette');\n        }\n\n        // ------------------------------------------------------------------\n        // 04. Color Object\n        // ------------------------------------------------------------------\n        {\n            const color = getColorSync(img1);\n            if (color) {\n                document.getElementById('color-preview').innerHTML =\n                    `<div class=\"color-preview-swatch\" style=\"background:${color.hex()};color:${color.textColor}\">Aa</div>\n                     <div class=\"color-preview-hex\">${color.hex()}</div>`;\n                renderColorTable(color, 'prop-table');\n            }\n            show('out-color-obj');\n        }\n\n        // ------------------------------------------------------------------\n        // 05. Semantic Swatches\n        // ------------------------------------------------------------------\n        {\n            const { result: swatches, ms } = timed(() => getSwatchesSync(img2));\n            const roles = ['Vibrant', 'Muted', 'DarkVibrant', 'DarkMuted', 'LightVibrant', 'LightMuted'];\n            document.getElementById('swatch-cards').innerHTML = roles.map(role => {\n                const s = swatches[role];\n                if (!s) {\n                    return `<div class=\"swatch-card swatch-card-empty\"><span class=\"role\">${role}</span></div>`;\n                }\n                return `<div class=\"swatch-card\" style=\"background:${s.color.hex()}\">\n                    <span class=\"role\" style=\"color:${s.titleTextColor.hex()}\">${role}</span>\n                    <span class=\"hex-label\" style=\"color:${s.bodyTextColor.hex()}\">${s.color.hex()}</span>\n                </div>`;\n            }).join('');\n            show('out-swatches');\n        }\n\n        // ------------------------------------------------------------------\n        // 06. OKLCH vs RGB\n        // ------------------------------------------------------------------\n        {\n            const rgb = getPaletteSync(img3, { colorCount: 8 });\n            const oklch = getPaletteSync(img3, { colorCount: 8, colorSpace: 'oklch' });\n            if (rgb) {\n                document.getElementById('oklch-rgb').innerHTML = rgb.map(c => swatchHTML(c, 'lg')).join('');\n            }\n            if (oklch) {\n                document.getElementById('oklch-oklch').innerHTML = oklch.map(c => swatchHTML(c, 'lg')).join('');\n            }\n            show('out-oklch');\n        }\n\n        // ------------------------------------------------------------------\n        // 07. Quality\n        // ------------------------------------------------------------------\n        {\n            const quals = [1, 10, 50];\n            const container = document.getElementById('out-quality');\n            container.insertAdjacentHTML('beforeend', quals.map(q => {\n                const { result: pal, ms } = timed(() => getPaletteSync(img1, { colorCount: 6, quality: q }));\n                return `<div class=\"quality-row\">\n                    <div class=\"quality-label\">quality: ${q} <span class=\"timing\">${ms}ms</span></div>\n                    <div class=\"quality-swatches\">${pal ? pal.map(c => swatchHTML(c, 'md')).join('') : ''}</div>\n                </div>`;\n            }).join(''));\n            show('out-quality');\n        }\n\n        // ------------------------------------------------------------------\n        // 08. Async + Workers\n        // ------------------------------------------------------------------\n        {\n            const container = document.getElementById('out-async');\n            const rows = [];\n\n            // Sync\n            const sync = timed(() => getPaletteSync(img1, { colorCount: 6 }));\n            rows.push({\n                label: 'Sync',\n                palette: sync.result,\n                ms: sync.ms,\n            });\n\n            // Async\n            const async_ = await timedAsync(() => getPalette(img1, { colorCount: 6 }));\n            rows.push({\n                label: 'Async',\n                palette: async_.result,\n                ms: async_.ms,\n            });\n\n            // Worker\n            try {\n                const worker = await timedAsync(() => getPalette(img1, { colorCount: 6, worker: true }));\n                rows.push({\n                    label: 'Async + Worker',\n                    palette: worker.result,\n                    ms: worker.ms,\n                });\n            } catch (e) {\n                rows.push({\n                    label: 'Async + Worker',\n                    palette: null,\n                    ms: 'unsupported',\n                });\n            }\n\n            container.insertAdjacentHTML('beforeend', rows.map(({ label, palette, ms }) =>\n                `<div class=\"async-row\">\n                    <div class=\"async-label\">${label} <span class=\"timing\">${ms}ms</span></div>\n                    <div class=\"async-swatches\">${palette ? palette.map(c => swatchHTML(c, 'sm')).join('') : '<span class=\"timing\">Not available</span>'}</div>\n                </div>`\n            ).join(''));\n            show('out-async');\n        }\n\n        // ------------------------------------------------------------------\n        // 09. Progressive\n        // ------------------------------------------------------------------\n        {\n            const fill = document.getElementById('prog-fill');\n            const stages = document.getElementById('prog-stages');\n            let stageIdx = 0;\n            const labels = ['Rough (16x skip)', 'Medium (4x skip)', 'Final (full quality)'];\n\n            for await (const { palette, progress, done } of getPaletteProgressive(img2, { colorCount: 6 })) {\n                fill.style.width = `${progress * 100}%`;\n                if (done) fill.style.background = '#16a34a';\n\n                const badgeClass = done ? 'stage-badge final' : 'stage-badge';\n                const html = `<div class=\"progressive-stage\">\n                    <div class=\"stage-header\">\n                        <span class=\"${badgeClass}\">${labels[stageIdx]}</span>\n                        <span class=\"timing\">${(progress * 100).toFixed(0)}%</span>\n                    </div>\n                    <div class=\"swatch-row\">${palette.map(c => swatchHTML(c, 'md')).join('')}</div>\n                </div>`;\n\n                stages.innerHTML += html;\n                stageIdx++;\n            }\n            show('out-progressive');\n        }\n\n        // ------------------------------------------------------------------\n        // 10. AbortController\n        // ------------------------------------------------------------------\n        {\n            const controller = new AbortController();\n            controller.abort();\n\n            try {\n                await getColor(img1, { signal: controller.signal });\n                document.getElementById('out-abort').insertAdjacentHTML('beforeend',\n                    '<div class=\"error-box\">Expected an error, but the call succeeded.</div>');\n            } catch (e) {\n                document.getElementById('out-abort').insertAdjacentHTML('beforeend',\n                    `<div class=\"error-box\">\n                        <strong>Caught error:</strong>\n                        ${e.name || 'Error'}: ${e.message || 'The operation was aborted.'}\n                    </div>`);\n            }\n            show('out-abort');\n        }\n\n        // ------------------------------------------------------------------\n        // 11. createColor\n        // ------------------------------------------------------------------\n        {\n            const color = createColor(232, 67, 147, 1);\n            document.getElementById('create-preview').innerHTML =\n                `<div class=\"color-preview-swatch\" style=\"background:${color.hex()};color:${color.textColor}\">Aa</div>\n                 <div class=\"color-preview-hex\">${color.hex()}</div>`;\n            renderColorTable(color, 'create-table');\n            show('out-create');\n        }\n\n        // ------------------------------------------------------------------\n        // 12. observe — Live Extraction\n        // ------------------------------------------------------------------\n        {\n            const video = document.getElementById('observe-video');\n            const glowWrap = document.getElementById('video-glow-wrap');\n            const playBtn = document.getElementById('video-play-btn');\n            const dominantEl = document.getElementById('observe-dominant');\n\n            // Wait for the video to have enough data\n            await new Promise((resolve) => {\n                if (video.readyState >= 2) return resolve();\n                video.addEventListener('loadeddata', resolve, { once: true });\n            });\n\n            // Start observing — dominant color updates live while video plays\n            const controller = observe(video, {\n                throttle: 200,\n                colorCount: 5,\n                onChange(palette) {\n                    const dominant = palette[0];\n\n                    // Update backlit glow\n                    glowWrap.style.setProperty('--glow-color', dominant.css());\n\n                    // Dominant color display\n                    dominantEl.innerHTML =\n                        `<div class=\"observe-dominant-swatch\" style=\"background:${dominant.hex()}\"></div>` +\n                        `<div class=\"observe-dominant-meta\">` +\n                            `<strong style=\"color:${dominant.hex()}\">${dominant.hex()}</strong>` +\n                        `</div>`;\n                },\n            });\n\n            // Toggle play/pause on click\n            function togglePlay() {\n                if (video.paused) {\n                    video.play();\n                    playBtn.classList.add('hidden');\n                } else {\n                    video.pause();\n                    playBtn.classList.remove('hidden');\n                }\n            }\n\n            playBtn.addEventListener('click', togglePlay);\n            video.addEventListener('click', togglePlay);\n\n            show('out-observe');\n        }\n\n    })();\n    </script>\n</body>\n</html>\n"
  },
  {
    "path": "package.json",
    "content": "{\n    \"name\": \"colorthief\",\n    \"version\": \"3.3.1\",\n    \"type\": \"module\",\n    \"author\": {\n        \"name\": \"Lokesh Dhakar\",\n        \"email\": \"lokesh.dhakar@gmail.com\",\n        \"url\": \"http://lokeshdhakar.com/\"\n    },\n    \"description\": \"Extract dominant colors and palettes from images — TypeScript, OKLCH, semantic swatches, live video extraction.\",\n    \"keywords\": [\n        \"color\",\n        \"palette\",\n        \"sampling\",\n        \"image\",\n        \"picture\",\n        \"photo\",\n        \"canvas\",\n        \"oklch\",\n        \"swatch\"\n    ],\n    \"homepage\": \"http://lokeshdhakar.com/projects/color-thief/\",\n    \"repository\": {\n        \"type\": \"git\",\n        \"url\": \"git+https://github.com/lokesh/color-thief.git\"\n    },\n    \"license\": \"MIT\",\n    \"bin\": {\n        \"colorthief\": \"dist/cli.js\"\n    },\n    \"files\": [\n        \"dist/\",\n        \"src/\"\n    ],\n    \"exports\": {\n        \".\": {\n            \"browser\": {\n                \"import\": {\n                    \"types\": \"./dist/types/index.d.ts\",\n                    \"default\": \"./dist/index.browser.js\"\n                },\n                \"require\": {\n                    \"types\": \"./dist/types/index.d.cts\",\n                    \"default\": \"./dist/index.browser.cjs\"\n                }\n            },\n            \"import\": {\n                \"types\": \"./dist/types/index.d.ts\",\n                \"default\": \"./dist/index.js\"\n            },\n            \"require\": {\n                \"types\": \"./dist/types/index.d.cts\",\n                \"default\": \"./dist/index.cjs\"\n            }\n        },\n        \"./internals\": {\n            \"browser\": {\n                \"import\": {\n                    \"types\": \"./dist/types/internals.browser.d.ts\",\n                    \"default\": \"./dist/internals.browser.js\"\n                },\n                \"require\": {\n                    \"types\": \"./dist/types/internals.browser.d.cts\",\n                    \"default\": \"./dist/internals.browser.cjs\"\n                }\n            },\n            \"import\": {\n                \"types\": \"./dist/types/internals.d.ts\",\n                \"default\": \"./dist/internals.js\"\n            },\n            \"require\": {\n                \"types\": \"./dist/types/internals.d.cts\",\n                \"default\": \"./dist/internals.cjs\"\n            }\n        },\n        \"./cli\": {\n            \"import\": \"./dist/cli.js\"\n        }\n    },\n    \"main\": \"./dist/index.cjs\",\n    \"module\": \"./dist/index.js\",\n    \"types\": \"./dist/types/index.d.ts\",\n    \"scripts\": {\n        \"prepublishOnly\": \"npm run build\",\n        \"build\": \"tsup\",\n        \"watch\": \"tsup --watch\",\n        \"dev\": \"http-server\",\n        \"test\": \"mocha && cypress run --config video=false\",\n        \"test:node\": \"mocha\",\n        \"test:browser\": \"cypress run --headed --browser chrome\",\n        \"cypress\": \"cypress open\",\n        \"typecheck\": \"tsc --noEmit\"\n    },\n    \"devDependencies\": {\n        \"@types/node\": \"^20.11.0\",\n        \"chai\": \"^4.2.0\",\n        \"chai-as-promised\": \"^7.1.1\",\n        \"cypress\": \"^13.15.0\",\n        \"http-server\": \"^14.1.1\",\n        \"mocha\": \"^10.2.0\",\n        \"mustache\": \"^3.0.1\",\n        \"sharp\": \"^0.34.5\",\n        \"tsup\": \"^8.0.0\",\n        \"typescript\": \"^5.3.0\"\n    },\n    \"peerDependencies\": {\n        \"sharp\": \">=0.33.0\"\n    },\n    \"peerDependenciesMeta\": {\n        \"sharp\": {\n            \"optional\": true\n        }\n    }\n}\n"
  },
  {
    "path": "src/api.ts",
    "content": "import type {\n    Color,\n    ExtractionOptions,\n    ImageSource,\n    PixelData,\n    PixelLoader,\n    ProgressiveResult,\n    Quantizer,\n    SwatchMap,\n} from './types.js';\nimport { validateOptions, extractPalette } from './pipeline.js';\nimport { extractProgressive } from './progressive.js';\nimport { classifySwatches } from './swatches.js';\nimport { MmcqQuantizer } from './quantizers/mmcq.js';\nimport { resolveDefaultLoader } from './resolve-loader.js';\n\n// ---------------------------------------------------------------------------\n// Global configuration\n// ---------------------------------------------------------------------------\n\nlet globalLoader: PixelLoader<ImageSource> | null = null;\nlet globalQuantizer: Quantizer | null = null;\n\n/**\n * Override the default pixel loader and/or quantizer.\n *\n * ```ts\n * import { configure } from 'colorthief';\n * import { WasmQuantizer } from 'colorthief/internals';\n * const q = new WasmQuantizer();\n * await q.init();\n * configure({ quantizer: q });\n * ```\n */\nexport function configure(opts: {\n    loader?: PixelLoader<ImageSource>;\n    quantizer?: Quantizer;\n}): void {\n    if (opts.loader) globalLoader = opts.loader;\n    if (opts.quantizer) globalQuantizer = opts.quantizer;\n}\n\n// ---------------------------------------------------------------------------\n// Lazy environment detection\n// ---------------------------------------------------------------------------\n\nasync function getLoader(perCall?: PixelLoader<ImageSource>): Promise<PixelLoader<ImageSource>> {\n    if (perCall) return perCall;\n    if (globalLoader) return globalLoader;\n    globalLoader = await resolveDefaultLoader();\n    return globalLoader;\n}\n\nasync function getQuantizer(perCall?: Quantizer): Promise<Quantizer> {\n    if (perCall) {\n        await perCall.init();\n        return perCall;\n    }\n    if (globalQuantizer) return globalQuantizer;\n    const q = new MmcqQuantizer();\n    await q.init();\n    globalQuantizer = q;\n    return q;\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction checkAborted(signal?: AbortSignal): void {\n    if (signal?.aborted) {\n        throw signal.reason ?? new DOMException('Aborted', 'AbortError');\n    }\n}\n\nasync function loadPixels(\n    source: ImageSource,\n    options?: ExtractionOptions,\n): Promise<PixelData> {\n    checkAborted(options?.signal);\n    const loader = await getLoader(options?.loader);\n    return loader.load(source, options?.signal);\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\n/**\n * Get the single dominant color from an image.\n *\n * ```ts\n * const color = await getColor(imgElement);\n * console.log(color.hex()); // '#e84393'\n * ```\n */\nexport async function getColor(\n    source: ImageSource,\n    options?: ExtractionOptions,\n): Promise<Color | null> {\n    const palette = await getPalette(source, {\n        colorCount: 5,\n        ...options,\n    });\n    return palette ? palette[0] : null;\n}\n\n/**\n * Get a color palette from an image.\n *\n * ```ts\n * const palette = await getPalette(imgElement, { colorCount: 5 });\n * palette.forEach(c => console.log(c.hex()));\n * ```\n */\nexport async function getPalette(\n    source: ImageSource,\n    options?: ExtractionOptions,\n): Promise<Color[] | null> {\n    const opts = validateOptions(options ?? {});\n\n    checkAborted(options?.signal);\n\n    // Worker path (browser only)\n    if (options?.worker) {\n        const { isWorkerSupported, extractInWorker } = await import(\n            './worker/manager.js'\n        );\n        if (isWorkerSupported()) {\n            const { data, width, height } = await loadPixels(source, options);\n            const { createPixelArray } = await import('./pipeline.js');\n            const pixelArray = createPixelArray(data, width * height, opts.quality, {\n                ignoreWhite: opts.ignoreWhite,\n                whiteThreshold: opts.whiteThreshold,\n                alphaThreshold: opts.alphaThreshold,\n                minSaturation: opts.minSaturation,\n            });\n            return extractInWorker(pixelArray, opts.colorCount, options?.signal);\n        }\n        // Fall through to main thread if workers not supported\n    }\n\n    const [pixels, quantizer] = await Promise.all([\n        loadPixels(source, options),\n        getQuantizer(options?.quantizer),\n    ]);\n\n    checkAborted(options?.signal);\n\n    return extractPalette(\n        pixels.data,\n        pixels.width,\n        pixels.height,\n        opts,\n        quantizer,\n    );\n}\n\n/**\n * Get semantic swatches (Vibrant, Muted, etc.) from an image.\n *\n * ```ts\n * const swatches = await getSwatches(imgElement);\n * console.log(swatches.Vibrant?.color.hex());\n * ```\n */\nexport async function getSwatches(\n    source: ImageSource,\n    options?: ExtractionOptions,\n): Promise<SwatchMap> {\n    const palette = await getPalette(source, {\n        colorCount: 16,\n        ...options,\n    });\n    return classifySwatches(palette ?? []);\n}\n\n/**\n * Progressively extract a palette with increasing quality.\n * Yields intermediate results so the UI can update incrementally.\n *\n * ```ts\n * for await (const { palette, progress, done } of getPaletteProgressive(img)) {\n *   updateUI(palette, progress);\n * }\n * ```\n */\nexport async function* getPaletteProgressive(\n    source: ImageSource,\n    options?: ExtractionOptions,\n): AsyncGenerator<ProgressiveResult> {\n    const opts = validateOptions(options ?? {});\n\n    const [pixels, quantizer] = await Promise.all([\n        loadPixels(source, options),\n        getQuantizer(options?.quantizer),\n    ]);\n\n    yield* extractProgressive(\n        pixels.data,\n        pixels.width,\n        pixels.height,\n        opts,\n        quantizer,\n        options?.signal,\n    );\n}\n"
  },
  {
    "path": "src/cli.ts",
    "content": "import { parseArgs } from 'node:util';\nimport { createRequire } from 'node:module';\nimport { readFileSync } from 'node:fs';\nimport { getColor, getPalette, getSwatches } from './api.js';\nimport type { Color, SwatchMap, SwatchRole } from './types.js';\n\n// ---------------------------------------------------------------------------\n// Version\n// ---------------------------------------------------------------------------\n\nconst require = createRequire(import.meta.url);\nconst { version } = require('../package.json');\n\n// ---------------------------------------------------------------------------\n// Help text\n// ---------------------------------------------------------------------------\n\nconst HELP = `\nUsage: colorthief [command] <file...> [options]\n\nCommands:\n  color      Extract dominant color (default)\n  palette    Extract color palette\n  swatches   Extract semantic swatches\n\nArguments:\n  <file...>  Image file path(s). Use \"-\" for stdin.\n\nOptions:\n  --json              Output as JSON\n  --css               Output as CSS custom properties\n  --count <n>         Number of palette colors (2-20, default 10)\n  --quality <n>       Sampling quality (1=best, default 10)\n  --color-space <s>   Color space: rgb or oklch (default oklch)\n  -h, --help          Show this help\n  -v, --version       Show version\n`.trim();\n\n// ---------------------------------------------------------------------------\n// Arg parsing\n// ---------------------------------------------------------------------------\n\ntype Command = 'color' | 'palette' | 'swatches';\n\ninterface CliArgs {\n    command: Command;\n    files: string[];\n    json: boolean;\n    css: boolean;\n    count: number;\n    quality: number;\n    colorSpace: 'rgb' | 'oklch';\n}\n\nfunction parseCliArgs(argv: string[]): CliArgs {\n    const { values, positionals } = parseArgs({\n        args: argv.slice(2),\n        options: {\n            json: { type: 'boolean', default: false },\n            css: { type: 'boolean', default: false },\n            count: { type: 'string', default: '10' },\n            quality: { type: 'string', default: '10' },\n            'color-space': { type: 'string', default: 'oklch' },\n            help: { type: 'boolean', short: 'h', default: false },\n            version: { type: 'boolean', short: 'v', default: false },\n        },\n        allowPositionals: true,\n        strict: true,\n    });\n\n    if (values.help) {\n        console.log(HELP);\n        process.exit(0);\n    }\n\n    if (values.version) {\n        console.log(version);\n        process.exit(0);\n    }\n\n    let command: Command = 'color';\n    const files: string[] = [];\n\n    for (const pos of positionals) {\n        if (pos === 'color' || pos === 'palette' || pos === 'swatches') {\n            if (files.length === 0) {\n                command = pos;\n                continue;\n            }\n        }\n        files.push(pos);\n    }\n\n    if (files.length === 0) {\n        // Check if stdin is piped\n        if (!process.stdin.isTTY) {\n            files.push('-');\n        } else {\n            console.error('Error: No input files specified.\\n');\n            console.log(HELP);\n            process.exit(1);\n        }\n    }\n\n    const count = parseInt(values.count as string, 10);\n    if (isNaN(count) || count < 2 || count > 20) {\n        console.error('Error: --count must be between 2 and 20.');\n        process.exit(1);\n    }\n\n    const quality = parseInt(values.quality as string, 10);\n    if (isNaN(quality) || quality < 1) {\n        console.error('Error: --quality must be a positive integer.');\n        process.exit(1);\n    }\n\n    const colorSpace = values['color-space'] as string;\n    if (colorSpace !== 'rgb' && colorSpace !== 'oklch') {\n        console.error('Error: --color-space must be \"rgb\" or \"oklch\".');\n        process.exit(1);\n    }\n\n    return {\n        command,\n        files,\n        json: values.json as boolean,\n        css: values.css as boolean,\n        count,\n        quality,\n        colorSpace,\n    };\n}\n\n// ---------------------------------------------------------------------------\n// Stdin reader\n// ---------------------------------------------------------------------------\n\nasync function readStdin(): Promise<Buffer> {\n    const chunks: Buffer[] = [];\n    for await (const chunk of process.stdin) {\n        chunks.push(chunk as Buffer);\n    }\n    return Buffer.concat(chunks);\n}\n\n// ---------------------------------------------------------------------------\n// Formatting helpers\n// ---------------------------------------------------------------------------\n\nconst supportsColor = !process.env['NO_COLOR'] && process.stdout.isTTY;\n\nfunction ansiSwatch(hex: string): string {\n    if (!supportsColor) return hex;\n    const r = parseInt(hex.slice(1, 3), 16);\n    const g = parseInt(hex.slice(3, 5), 16);\n    const b = parseInt(hex.slice(5, 7), 16);\n    return `\\x1b[38;2;${r};${g};${b}m\\u2587\\u2587\\x1b[0m ${hex}`;\n}\n\nfunction colorToJson(c: Color): Record<string, unknown> {\n    return {\n        hex: c.hex(),\n        rgb: c.rgb(),\n        hsl: c.hsl(),\n        oklch: c.oklch(),\n        isDark: c.isDark,\n        population: c.population,\n        proportion: c.proportion,\n    };\n}\n\nfunction swatchMapToJson(swatches: SwatchMap): Record<string, unknown> {\n    const result: Record<string, unknown> = {};\n    for (const role of Object.keys(swatches) as SwatchRole[]) {\n        const s = swatches[role];\n        result[role] = s ? colorToJson(s.color) : null;\n    }\n    return result;\n}\n\n// ---------------------------------------------------------------------------\n// CSS output\n// ---------------------------------------------------------------------------\n\nfunction colorCss(c: Color): string {\n    return `:root {\\n    --color-dominant: ${c.hex()};\\n}`;\n}\n\nfunction paletteCss(colors: Color[]): string {\n    const props = colors.map((c, i) => `    --color-${i + 1}: ${c.hex()};`).join('\\n');\n    return `:root {\\n${props}\\n}`;\n}\n\nfunction swatchesCss(swatches: SwatchMap): string {\n    const props: string[] = [];\n    for (const role of Object.keys(swatches) as SwatchRole[]) {\n        const s = swatches[role];\n        const kebab = role.replace(/([A-Z])/g, '-$1').toLowerCase();\n        props.push(`    --swatch${kebab}: ${s ? s.color.hex() : 'none'};`);\n    }\n    return `:root {\\n${props.join('\\n')}\\n}`;\n}\n\n// ---------------------------------------------------------------------------\n// ANSI output\n// ---------------------------------------------------------------------------\n\nfunction colorAnsi(c: Color): string {\n    return ansiSwatch(c.hex());\n}\n\nfunction paletteAnsi(colors: Color[]): string {\n    return colors.map(c => ansiSwatch(c.hex())).join('\\n');\n}\n\nfunction swatchesAnsi(swatches: SwatchMap): string {\n    const lines: string[] = [];\n    for (const role of Object.keys(swatches) as SwatchRole[]) {\n        const s = swatches[role];\n        const label = role.padEnd(14);\n        lines.push(s ? `${label} ${ansiSwatch(s.color.hex())}` : `${label} —`);\n    }\n    return lines.join('\\n');\n}\n\n// ---------------------------------------------------------------------------\n// Main\n// ---------------------------------------------------------------------------\n\nasync function processFile(\n    source: string | Buffer,\n    args: CliArgs,\n): Promise<{ colorResult?: Color | null; paletteResult?: Color[] | null; swatchResult?: SwatchMap }> {\n    const opts = {\n        colorCount: args.count,\n        quality: args.quality,\n        colorSpace: args.colorSpace as 'rgb' | 'oklch',\n    };\n\n    switch (args.command) {\n        case 'color': {\n            const color = await getColor(source, opts);\n            return { colorResult: color };\n        }\n        case 'palette': {\n            const palette = await getPalette(source, opts);\n            return { paletteResult: palette };\n        }\n        case 'swatches': {\n            const swatches = await getSwatches(source, opts);\n            return { swatchResult: swatches };\n        }\n    }\n}\n\nfunction formatResult(\n    result: Awaited<ReturnType<typeof processFile>>,\n    args: CliArgs,\n): string {\n    if (args.json) {\n        if (result.colorResult !== undefined) {\n            return JSON.stringify(result.colorResult ? colorToJson(result.colorResult) : null, null, 2);\n        }\n        if (result.paletteResult !== undefined) {\n            return JSON.stringify(result.paletteResult ? result.paletteResult.map(colorToJson) : null, null, 2);\n        }\n        if (result.swatchResult !== undefined) {\n            return JSON.stringify(swatchMapToJson(result.swatchResult), null, 2);\n        }\n    }\n\n    if (args.css) {\n        if (result.colorResult !== undefined && result.colorResult) {\n            return colorCss(result.colorResult);\n        }\n        if (result.paletteResult !== undefined && result.paletteResult) {\n            return paletteCss(result.paletteResult);\n        }\n        if (result.swatchResult !== undefined) {\n            return swatchesCss(result.swatchResult!);\n        }\n    }\n\n    // Default ANSI\n    if (result.colorResult !== undefined) {\n        return result.colorResult ? colorAnsi(result.colorResult) : '(no color found)';\n    }\n    if (result.paletteResult !== undefined) {\n        return result.paletteResult ? paletteAnsi(result.paletteResult) : '(no palette found)';\n    }\n    if (result.swatchResult !== undefined) {\n        return swatchesAnsi(result.swatchResult);\n    }\n\n    return '';\n}\n\nasync function main(): Promise<void> {\n    const args = parseCliArgs(process.argv);\n    const multiFile = args.files.length > 1;\n\n    if (args.json && multiFile) {\n        const combined: Record<string, unknown> = {};\n        for (const file of args.files) {\n            const source = file === '-' ? await readStdin() : file;\n            const label = file === '-' ? 'stdin' : file;\n            const result = await processFile(source, args);\n\n            if (result.colorResult !== undefined) {\n                combined[label] = result.colorResult ? colorToJson(result.colorResult) : null;\n            } else if (result.paletteResult !== undefined) {\n                combined[label] = result.paletteResult ? result.paletteResult.map(colorToJson) : null;\n            } else if (result.swatchResult !== undefined) {\n                combined[label] = swatchMapToJson(result.swatchResult);\n            }\n        }\n        console.log(JSON.stringify(combined, null, 2));\n        return;\n    }\n\n    for (const file of args.files) {\n        const source = file === '-' ? await readStdin() : file;\n        const result = await processFile(source, args);\n        const output = formatResult(result, args);\n\n        if (multiFile) {\n            const label = file === '-' ? 'stdin' : file;\n            console.log(`${label}:`);\n        }\n        console.log(output);\n    }\n}\n\nmain().catch((err) => {\n    if (\n        err?.code === 'ERR_MODULE_NOT_FOUND' ||\n        err?.code === 'MODULE_NOT_FOUND' ||\n        (typeof err?.message === 'string' && err.message.includes('Cannot find module') && err.message.includes('sharp'))\n    ) {\n        console.error(\n            'Error: sharp is required for image decoding but was not found.\\n\\n' +\n            'Install it alongside colorthief:\\n\\n' +\n            '  npm install -g colorthief sharp\\n'\n        );\n        process.exit(1);\n    }\n    console.error(err.message || err);\n    process.exit(1);\n});\n"
  },
  {
    "path": "src/color-space.ts",
    "content": "import type { OKLCH } from './types.js';\n\n// ---------------------------------------------------------------------------\n// sRGB ↔ Linear\n// ---------------------------------------------------------------------------\n\n/** Convert a single sRGB channel (0–255) to linear (0–1). */\nexport function srgbToLinear(c: number): number {\n    const s = c / 255;\n    return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);\n}\n\n/** Convert a linear channel (0–1) back to sRGB (0–255). */\nexport function linearToSrgb(c: number): number {\n    const s = c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;\n    return Math.round(Math.max(0, Math.min(255, s * 255)));\n}\n\n// ---------------------------------------------------------------------------\n// RGB → OKLab → OKLCH\n// ---------------------------------------------------------------------------\n\n/** Convert sRGB (0–255 each) to OKLCH. */\nexport function rgbToOklch(r: number, g: number, b: number): OKLCH {\n    // sRGB → linear\n    const lr = srgbToLinear(r);\n    const lg = srgbToLinear(g);\n    const lb = srgbToLinear(b);\n\n    // Linear sRGB → LMS (using Oklab M1 matrix)\n    const l_ = 0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb;\n    const m_ = 0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb;\n    const s_ = 0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb;\n\n    // Cube root (LMS → Lab cone response)\n    const l3 = Math.cbrt(l_);\n    const m3 = Math.cbrt(m_);\n    const s3 = Math.cbrt(s_);\n\n    // LMS cone response → OKLab\n    const L = 0.2104542553 * l3 + 0.7936177850 * m3 - 0.0040720468 * s3;\n    const a = 1.9779984951 * l3 - 2.4285922050 * m3 + 0.4505937099 * s3;\n    const bLab = 0.0259040371 * l3 + 0.7827717662 * m3 - 0.8086757660 * s3;\n\n    // OKLab → OKLCH\n    const C = Math.sqrt(a * a + bLab * bLab);\n    let H = Math.atan2(bLab, a) * (180 / Math.PI);\n    if (H < 0) H += 360;\n\n    return { l: L, c: C, h: H };\n}\n\n// ---------------------------------------------------------------------------\n// OKLCH → OKLab → RGB\n// ---------------------------------------------------------------------------\n\n/** Convert OKLCH back to sRGB (0–255 each). Clamps out-of-gamut values. */\nexport function oklchToRgb(l: number, c: number, h: number): [number, number, number] {\n    // OKLCH → OKLab\n    const hRad = h * (Math.PI / 180);\n    const a = c * Math.cos(hRad);\n    const bLab = c * Math.sin(hRad);\n\n    // OKLab → LMS cone response\n    const l3 = l + 0.3963377774 * a + 0.2158037573 * bLab;\n    const m3 = l - 0.1055613458 * a - 0.0638541728 * bLab;\n    const s3 = l - 0.0894841775 * a - 1.2914855480 * bLab;\n\n    // Cube (cone response → LMS)\n    const l_ = l3 * l3 * l3;\n    const m_ = m3 * m3 * m3;\n    const s_ = s3 * s3 * s3;\n\n    // LMS → linear sRGB (inverse of M1)\n    const lr = +4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_;\n    const lg = -1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_;\n    const lb = -0.0041960863 * l_ - 0.7034186147 * m_ + 1.7076147010 * s_;\n\n    return [linearToSrgb(lr), linearToSrgb(lg), linearToSrgb(lb)];\n}\n\n// ---------------------------------------------------------------------------\n// Batch conversion helpers for OKLCH quantization pipeline\n// ---------------------------------------------------------------------------\n\n/**\n * Convert an array of RGB pixel triplets to OKLCH, scaled to 0–255 for\n * compatibility with the MMCQ quantizer (which expects integer ranges).\n *\n * Scaling: L (0–1) → 0–255, C (0–0.4) → 0–255, H (0–360) → 0–255\n */\nexport function pixelsRgbToOklchScaled(\n    pixels: Array<[number, number, number]>,\n): Array<[number, number, number]> {\n    const out: Array<[number, number, number]> = new Array(pixels.length);\n    for (let i = 0; i < pixels.length; i++) {\n        const [r, g, b] = pixels[i];\n        const { l, c, h } = rgbToOklch(r, g, b);\n        out[i] = [\n            Math.round(l * 255),\n            Math.round((c / 0.4) * 255),\n            Math.round((h / 360) * 255),\n        ];\n    }\n    return out;\n}\n\n/**\n * Convert scaled OKLCH palette entries back to RGB.\n */\nexport function paletteOklchScaledToRgb(\n    colors: Array<{ color: [number, number, number]; population: number }>,\n): Array<{ color: [number, number, number]; population: number }> {\n    return colors.map(({ color: [ls, cs, hs], population }) => {\n        const l = ls / 255;\n        const c = (cs / 255) * 0.4;\n        const h = (hs / 255) * 360;\n        return { color: oklchToRgb(l, c, h), population };\n    });\n}\n"
  },
  {
    "path": "src/color.ts",
    "content": "import type { RGB, HSL, OKLCH, Color, ContrastInfo, CssColorFormat } from './types.js';\nimport { rgbToOklch } from './color-space.js';\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\nfunction rgbToHsl(r: number, g: number, b: number): HSL {\n    const r1 = r / 255;\n    const g1 = g / 255;\n    const b1 = b / 255;\n    const max = Math.max(r1, g1, b1);\n    const min = Math.min(r1, g1, b1);\n    const l = (max + min) / 2;\n    let h = 0;\n    let s = 0;\n\n    if (max !== min) {\n        const d = max - min;\n        s = l > 0.5 ? d / (2 - max - min) : d / (max + min);\n\n        if (max === r1) {\n            h = ((g1 - b1) / d + (g1 < b1 ? 6 : 0)) / 6;\n        } else if (max === g1) {\n            h = ((b1 - r1) / d + 2) / 6;\n        } else {\n            h = ((r1 - g1) / d + 4) / 6;\n        }\n    }\n\n    return {\n        h: Math.round(h * 360),\n        s: Math.round(s * 100),\n        l: Math.round(l * 100),\n    };\n}\n\n/** WCAG relative luminance from sRGB 0–255. */\nfunction relativeLuminance(r: number, g: number, b: number): number {\n    const toLinear = (c: number) => {\n        const s = c / 255;\n        return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);\n    };\n    return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);\n}\n\n/** WCAG contrast ratio between two luminances (always ≥ 1). */\nfunction contrastRatio(l1: number, l2: number): number {\n    const lighter = Math.max(l1, l2);\n    const darker = Math.min(l1, l2);\n    return (lighter + 0.05) / (darker + 0.05);\n}\n\n// ---------------------------------------------------------------------------\n// ColorImpl\n// ---------------------------------------------------------------------------\n\nclass ColorImpl implements Color {\n    private readonly _r: number;\n    private readonly _g: number;\n    private readonly _b: number;\n    readonly population: number;\n    readonly proportion: number;\n\n    private _hsl: HSL | undefined;\n    private _oklch: OKLCH | undefined;\n    private _luminance: number | undefined;\n    private _contrast: ContrastInfo | undefined;\n\n    constructor(r: number, g: number, b: number, population: number, proportion: number) {\n        this._r = r;\n        this._g = g;\n        this._b = b;\n        this.population = population;\n        this.proportion = proportion;\n    }\n\n    rgb(): RGB {\n        return { r: this._r, g: this._g, b: this._b };\n    }\n\n    hex(): string {\n        const toHex = (n: number) => n.toString(16).padStart(2, '0');\n        return `#${toHex(this._r)}${toHex(this._g)}${toHex(this._b)}`;\n    }\n\n    hsl(): HSL {\n        if (!this._hsl) {\n            this._hsl = rgbToHsl(this._r, this._g, this._b);\n        }\n        return this._hsl;\n    }\n\n    oklch(): OKLCH {\n        if (!this._oklch) {\n            this._oklch = rgbToOklch(this._r, this._g, this._b);\n        }\n        return this._oklch;\n    }\n\n    css(format: CssColorFormat = 'rgb'): string {\n        switch (format) {\n            case 'hsl': {\n                const { h, s, l } = this.hsl();\n                return `hsl(${h}, ${s}%, ${l}%)`;\n            }\n            case 'oklch': {\n                const { l, c, h } = this.oklch();\n                return `oklch(${l.toFixed(3)} ${c.toFixed(3)} ${h.toFixed(1)})`;\n            }\n            case 'rgb':\n            default:\n                return `rgb(${this._r}, ${this._g}, ${this._b})`;\n        }\n    }\n\n    array(): [number, number, number] {\n        return [this._r, this._g, this._b];\n    }\n\n    toString(): string {\n        return this.hex();\n    }\n\n    get textColor(): string {\n        return this.isDark ? '#ffffff' : '#000000';\n    }\n\n    private get luminance(): number {\n        if (this._luminance === undefined) {\n            this._luminance = relativeLuminance(this._r, this._g, this._b);\n        }\n        return this._luminance;\n    }\n\n    get isDark(): boolean {\n        return this.luminance <= 0.179;\n    }\n\n    get isLight(): boolean {\n        return !this.isDark;\n    }\n\n    get contrast(): ContrastInfo {\n        if (!this._contrast) {\n            const lum = this.luminance;\n            const white = contrastRatio(lum, 1); // white luminance = 1\n            const black = contrastRatio(lum, 0); // black luminance = 0\n            const foreground = this.isDark\n                ? createColor(255, 255, 255, 0, 0)\n                : createColor(0, 0, 0, 0, 0);\n            this._contrast = {\n                white: Math.round(white * 100) / 100,\n                black: Math.round(black * 100) / 100,\n                foreground,\n            };\n        }\n        return this._contrast;\n    }\n}\n\n// ---------------------------------------------------------------------------\n// Factory\n// ---------------------------------------------------------------------------\n\n/** Create a Color object from RGB components, population count, and proportion. */\nexport function createColor(\n    r: number,\n    g: number,\n    b: number,\n    population: number,\n    proportion: number = 0,\n): Color {\n    return new ColorImpl(r, g, b, population, proportion);\n}\n"
  },
  {
    "path": "src/index.ts",
    "content": "// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\nexport {\n    getColor,\n    getPalette,\n    getSwatches,\n    getPaletteProgressive,\n    configure,\n} from './api.js';\n\n// ---------------------------------------------------------------------------\n// Sync browser API\n// ---------------------------------------------------------------------------\nexport {\n    getColorSync,\n    getPaletteSync,\n    getSwatchesSync,\n} from './sync.js';\n\n// ---------------------------------------------------------------------------\n// Live extraction (browser only)\n// ---------------------------------------------------------------------------\nexport { observe } from './observe.js';\nexport type { ObservableSource, ObserveOptions, ObserveController } from './observe.js';\n\n// ---------------------------------------------------------------------------\n// Color factory\n// ---------------------------------------------------------------------------\nexport { createColor } from './color.js';\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\nexport type {\n    RGB,\n    HSL,\n    OKLCH,\n    FilterOptions,\n    ColorSpace,\n    ExtractionOptions,\n    ContrastInfo,\n    Color,\n    CssColorFormat,\n    SwatchRole,\n    Swatch,\n    SwatchMap,\n    BrowserSource,\n    NodeSource,\n    ImageSource,\n    ProgressiveResult,\n} from './types.js';\n\nexport type { SyncExtractionOptions } from './sync.js';\n"
  },
  {
    "path": "src/internals.browser.ts",
    "content": "// ---------------------------------------------------------------------------\n// colorthief/internals (browser build)\n//\n// Same as internals.ts but without Node-specific exports (NodePixelLoader,\n// createNodeLoader, NodeImageDecoder) so browser bundlers never see sharp.\n// ---------------------------------------------------------------------------\n\n// Quantizers\nexport { MmcqQuantizer } from './quantizers/mmcq.js';\nexport { WasmQuantizer } from './quantizers/wasm.js';\n\n// Loaders\nexport { BrowserPixelLoader } from './loaders/browser.js';\n\n// Swatches (standalone classifier)\nexport { classifySwatches } from './swatches.js';\n\n// Color space conversions\nexport {\n    rgbToOklch,\n    oklchToRgb,\n    srgbToLinear,\n    linearToSrgb,\n    pixelsRgbToOklchScaled,\n    paletteOklchScaledToRgb,\n} from './color-space.js';\n\n// Worker manager\nexport {\n    isWorkerSupported,\n    extractInWorker,\n    terminateWorker,\n} from './worker/manager.js';\n\n// Low-level pipeline\nexport {\n    validateOptions,\n    createPixelArray,\n    computeFallbackColor,\n    extractPalette,\n} from './pipeline.js';\n\n// Types not needed by most consumers\nexport type {\n    PixelBuffer,\n    PixelData,\n    PixelLoader,\n    Quantizer,\n} from './types.js';\n"
  },
  {
    "path": "src/internals.ts",
    "content": "// ---------------------------------------------------------------------------\n// colorthief/internals\n//\n// Power-user exports: loaders, quantizers, color-space math, worker manager.\n// Most consumers should use the main 'colorthief' entry point instead.\n// ---------------------------------------------------------------------------\n\n// Quantizers\nexport { MmcqQuantizer } from './quantizers/mmcq.js';\nexport { WasmQuantizer } from './quantizers/wasm.js';\n\n// Loaders\nexport { BrowserPixelLoader } from './loaders/browser.js';\nexport { NodePixelLoader, createNodeLoader } from './loaders/node.js';\nexport type { NodeImageDecoder } from './loaders/node.js';\n\n// Swatches (standalone classifier)\nexport { classifySwatches } from './swatches.js';\n\n// Color space conversions\nexport {\n    rgbToOklch,\n    oklchToRgb,\n    srgbToLinear,\n    linearToSrgb,\n    pixelsRgbToOklchScaled,\n    paletteOklchScaledToRgb,\n} from './color-space.js';\n\n// Worker manager\nexport {\n    isWorkerSupported,\n    extractInWorker,\n    terminateWorker,\n} from './worker/manager.js';\n\n// Low-level pipeline\nexport {\n    validateOptions,\n    createPixelArray,\n    computeFallbackColor,\n    extractPalette,\n} from './pipeline.js';\n\n// Types not needed by most consumers\nexport type {\n    PixelBuffer,\n    PixelData,\n    PixelLoader,\n    Quantizer,\n} from './types.js';\n"
  },
  {
    "path": "src/loaders/browser.ts",
    "content": "import type { BrowserSource, PixelData, PixelLoader } from '../types.js';\n\n/**\n * Browser pixel loader. Extracts RGBA pixel data from DOM image sources\n * using an off-screen canvas.\n */\nexport class BrowserPixelLoader implements PixelLoader<BrowserSource> {\n    async load(source: BrowserSource): Promise<PixelData> {\n        if (typeof HTMLImageElement !== 'undefined' && source instanceof HTMLImageElement) {\n            return this.loadFromImage(source);\n        }\n        if (typeof HTMLCanvasElement !== 'undefined' && source instanceof HTMLCanvasElement) {\n            return this.loadFromCanvas(source);\n        }\n        if (typeof ImageData !== 'undefined' && source instanceof ImageData) {\n            return {\n                data: source.data,\n                width: source.width,\n                height: source.height,\n            };\n        }\n        if (typeof HTMLVideoElement !== 'undefined' && source instanceof HTMLVideoElement) {\n            return this.loadFromVideo(source);\n        }\n        if (typeof ImageBitmap !== 'undefined' && source instanceof ImageBitmap) {\n            return this.loadFromImageBitmap(source);\n        }\n        if (typeof OffscreenCanvas !== 'undefined' && source instanceof OffscreenCanvas) {\n            return this.loadFromOffscreenCanvas(source);\n        }\n        throw new Error(\n            'Unsupported source type. Expected HTMLImageElement, HTMLCanvasElement, HTMLVideoElement, ImageData, ImageBitmap, or OffscreenCanvas.',\n        );\n    }\n\n    private loadFromImage(img: HTMLImageElement): PixelData {\n        if (!img.complete) {\n            throw new Error(\n                'Image has not finished loading. Wait for the \"load\" event before calling getColor/getPalette.',\n            );\n        }\n        if (!img.naturalWidth) {\n            throw new Error(\n                'Image has no dimensions. It may not have loaded successfully.',\n            );\n        }\n        const canvas = document.createElement('canvas');\n        const ctx = canvas.getContext('2d')!;\n        const width = (canvas.width = img.naturalWidth);\n        const height = (canvas.height = img.naturalHeight);\n        ctx.drawImage(img, 0, 0, width, height);\n        try {\n            const imageData = ctx.getImageData(0, 0, width, height);\n            return { data: imageData.data, width, height };\n        } catch (e: unknown) {\n            if (e instanceof DOMException && e.name === 'SecurityError') {\n                const err = new Error(\n                    'Image is tainted by cross-origin data. Add crossorigin=\"anonymous\" to the <img> tag and ensure the server sends appropriate CORS headers.',\n                );\n                err.cause = e;\n                throw err;\n            }\n            throw e;\n        }\n    }\n\n    private loadFromCanvas(canvas: HTMLCanvasElement): PixelData {\n        const ctx = canvas.getContext('2d')!;\n        const { width, height } = canvas;\n        const imageData = ctx.getImageData(0, 0, width, height);\n        return { data: imageData.data, width, height };\n    }\n\n    private loadFromVideo(video: HTMLVideoElement): PixelData {\n        if (video.readyState < 2) {\n            throw new Error(\n                'Video is not ready. Wait for the \"loadeddata\" or \"canplay\" event before calling getColor/getPalette.',\n            );\n        }\n        const width = video.videoWidth;\n        const height = video.videoHeight;\n        if (!width || !height) {\n            throw new Error(\n                'Video has no dimensions. It may not have loaded successfully.',\n            );\n        }\n        const canvas = document.createElement('canvas');\n        const ctx = canvas.getContext('2d')!;\n        canvas.width = width;\n        canvas.height = height;\n        ctx.drawImage(video, 0, 0, width, height);\n        const imageData = ctx.getImageData(0, 0, width, height);\n        return { data: imageData.data, width, height };\n    }\n\n    private loadFromOffscreenCanvas(canvas: OffscreenCanvas): PixelData {\n        const ctx = canvas.getContext('2d') as OffscreenCanvasRenderingContext2D;\n        if (!ctx) {\n            throw new Error(\n                'Could not get 2D context from OffscreenCanvas.',\n            );\n        }\n        const { width, height } = canvas;\n        const imageData = ctx.getImageData(0, 0, width, height);\n        return { data: imageData.data, width, height };\n    }\n\n    private loadFromImageBitmap(bitmap: ImageBitmap): PixelData {\n        const canvas = document.createElement('canvas');\n        const ctx = canvas.getContext('2d')!;\n        canvas.width = bitmap.width;\n        canvas.height = bitmap.height;\n        ctx.drawImage(bitmap, 0, 0);\n        const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);\n        return { data: imageData.data, width: bitmap.width, height: bitmap.height };\n    }\n}\n"
  },
  {
    "path": "src/loaders/node.ts",
    "content": "import type { NodeSource, PixelData, PixelLoader } from '../types.js';\n\n/** Custom decoder signature for pluggable Node decoders. */\nexport type NodeImageDecoder = (\n    input: string | Buffer,\n) => Promise<{ data: Uint8Array; width: number; height: number }>;\n\ninterface NodeLoaderOptions {\n    /** Override the default sharp-based decoder. */\n    decoder?: NodeImageDecoder;\n}\n\n/**\n * Node.js pixel loader. Uses `sharp` (dynamically imported) to decode images\n * into raw RGBA pixel buffers. Accepts file paths or Buffers.\n *\n * The sharp dependency is optional — use `createNodeLoader({ decoder })`\n * to supply a custom decoder if sharp is not available.\n */\nexport class NodePixelLoader implements PixelLoader<NodeSource> {\n    private readonly decoder: NodeImageDecoder;\n\n    constructor(options?: NodeLoaderOptions) {\n        this.decoder = options?.decoder ?? defaultSharpDecoder;\n    }\n\n    async load(source: NodeSource): Promise<PixelData> {\n        const result = await this.decoder(source);\n        return {\n            data: result.data,\n            width: result.width,\n            height: result.height,\n        };\n    }\n}\n\n/** Default decoder using sharp. Dynamically imports sharp so it stays optional. */\nasync function defaultSharpDecoder(\n    input: string | Buffer,\n): Promise<{ data: Uint8Array; width: number; height: number }> {\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    let sharpFn: any;\n    try {\n        const mod = await import('sharp');\n        sharpFn = mod.default ?? mod;\n    } catch {\n        throw new Error(\n            'sharp is required for Node.js image loading. Install it with: npm install sharp',\n        );\n    }\n    const image = sharpFn(input).ensureAlpha();\n    const { width, height } = await image.metadata();\n    if (!width || !height) {\n        throw new Error('Could not determine image dimensions.');\n    }\n    const { data } = await image.raw().toBuffer({ resolveWithObject: true });\n    return { data: new Uint8Array(data.buffer, data.byteOffset, data.byteLength), width, height };\n}\n\n/** Factory to create a NodePixelLoader with optional custom decoder. */\nexport function createNodeLoader(options?: NodeLoaderOptions): NodePixelLoader {\n    return new NodePixelLoader(options);\n}\n"
  },
  {
    "path": "src/observe.ts",
    "content": "/**\n * Live extraction mode — observe() with reactive updates.\n *\n * Watches a source element (video, canvas, or img) and emits palette\n * updates via an onChange callback. Uses requestAnimationFrame with\n * throttle for video/canvas, and MutationObserver for img src changes.\n *\n * Browser-only — relies on DOM APIs.\n */\nimport type { Color, ColorSpace, FilterOptions } from './types.js';\nimport { getPaletteSync } from './sync.js';\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\n/** Element types that can be observed for live palette updates. */\nexport type ObservableSource =\n    | HTMLVideoElement\n    | HTMLCanvasElement\n    | HTMLImageElement;\n\n/** Options for observe(). */\nexport interface ObserveOptions extends FilterOptions {\n    /** Minimum milliseconds between palette updates. @default 200 */\n    throttle?: number;\n    /** Number of colors in the palette (2–20). @default 5 */\n    colorCount?: number;\n    /** Sampling quality (1 = highest). @default 10 */\n    quality?: number;\n    /** Color space for quantization. @default 'oklch' */\n    colorSpace?: ColorSpace;\n    /** Called whenever a new palette is extracted. */\n    onChange: (palette: Color[]) => void;\n}\n\n/** Controller returned by observe() to stop watching. */\nexport interface ObserveController {\n    /** Stop observing and clean up all listeners/timers. */\n    stop(): void;\n}\n\n// ---------------------------------------------------------------------------\n// Implementation\n// ---------------------------------------------------------------------------\n\n/**\n * Watch a source element and reactively extract palettes as it changes.\n *\n * - **HTMLVideoElement** — extracts from the current frame on each animation\n *   frame (throttled). Only runs while the video is playing.\n * - **HTMLCanvasElement** — polls on each animation frame (throttled).\n * - **HTMLImageElement** — extracts immediately, then watches for `src`/`srcset`\n *   attribute changes via MutationObserver.\n *\n * ```ts\n * const controller = observe(videoElement, {\n *     throttle: 200,\n *     colorCount: 5,\n *     onChange(palette) {\n *         updateAmbientBackground(palette);\n *     },\n * });\n *\n * // Later\n * controller.stop();\n * ```\n */\nexport function observe(\n    source: ObservableSource,\n    options: ObserveOptions,\n): ObserveController {\n    const {\n        throttle = 200,\n        onChange,\n        ...extractionOptions\n    } = options;\n\n    let stopped = false;\n    let rafId: number | null = null;\n    let mutationObserver: MutationObserver | null = null;\n    let lastExtractTime = 0;\n\n    // Cleanup handles for event listeners\n    const cleanups: Array<() => void> = [];\n\n    function extract(): void {\n        try {\n            const palette = getPaletteSync(source, extractionOptions);\n            if (palette && palette.length > 0) {\n                onChange(palette);\n            }\n        } catch {\n            // Skip this frame on error (CORS, not loaded, etc.)\n        }\n    }\n\n    function tick(): void {\n        if (stopped) return;\n\n        const now = performance.now();\n        if (now - lastExtractTime >= throttle) {\n            if (source instanceof HTMLVideoElement) {\n                // Only extract when the video has data and is playing\n                if (source.readyState >= 2 && !source.paused && !source.ended) {\n                    extract();\n                    lastExtractTime = now;\n                }\n            } else {\n                // Canvas — always extract\n                extract();\n                lastExtractTime = now;\n            }\n        }\n\n        rafId = requestAnimationFrame(tick);\n    }\n\n    // ----- HTMLImageElement: MutationObserver-based -----\n    if (source instanceof HTMLImageElement) {\n        // Extract immediately if already loaded\n        if (source.complete && source.naturalWidth) {\n            extract();\n        } else {\n            const onLoad = (): void => {\n                extract();\n                source.removeEventListener('load', onLoad);\n            };\n            source.addEventListener('load', onLoad);\n            cleanups.push(() => source.removeEventListener('load', onLoad));\n        }\n\n        // Watch for src / srcset attribute changes\n        mutationObserver = new MutationObserver(() => {\n            if (source.complete && source.naturalWidth) {\n                extract();\n            } else {\n                const onLoad = (): void => {\n                    extract();\n                    source.removeEventListener('load', onLoad);\n                };\n                source.addEventListener('load', onLoad);\n            }\n        });\n        mutationObserver.observe(source, {\n            attributes: true,\n            attributeFilter: ['src', 'srcset'],\n        });\n\n    // ----- HTMLVideoElement: rAF + play/pause awareness -----\n    } else if (source instanceof HTMLVideoElement) {\n        // Start the rAF loop — it checks readyState/paused internally\n        rafId = requestAnimationFrame(tick);\n\n        // Also extract on seeked so scrubbing works\n        const onSeeked = (): void => {\n            if (!stopped) extract();\n        };\n        source.addEventListener('seeked', onSeeked);\n        cleanups.push(() => source.removeEventListener('seeked', onSeeked));\n\n    // ----- HTMLCanvasElement: rAF polling -----\n    } else {\n        rafId = requestAnimationFrame(tick);\n    }\n\n    return {\n        stop(): void {\n            stopped = true;\n            if (rafId !== null) {\n                cancelAnimationFrame(rafId);\n                rafId = null;\n            }\n            if (mutationObserver) {\n                mutationObserver.disconnect();\n                mutationObserver = null;\n            }\n            for (const fn of cleanups) fn();\n            cleanups.length = 0;\n        },\n    };\n}\n"
  },
  {
    "path": "src/pipeline.ts",
    "content": "import type {\n    Color,\n    ExtractionOptions,\n    FilterOptions,\n    PixelBuffer,\n    Quantizer,\n} from './types.js';\nimport { createColor } from './color.js';\nimport {\n    pixelsRgbToOklchScaled,\n    paletteOklchScaledToRgb,\n} from './color-space.js';\n\n// ---------------------------------------------------------------------------\n// Validate & normalize options\n// ---------------------------------------------------------------------------\n\nexport interface ValidatedOptions {\n    colorCount: number;\n    quality: number;\n    ignoreWhite: boolean;\n    whiteThreshold: number;\n    alphaThreshold: number;\n    minSaturation: number;\n    colorSpace: 'rgb' | 'oklch';\n}\n\nexport function validateOptions(options: ExtractionOptions): ValidatedOptions {\n    let { colorCount, quality } = options;\n\n    if (typeof colorCount === 'undefined' || !Number.isInteger(colorCount)) {\n        colorCount = 10;\n    } else if (colorCount === 1) {\n        throw new Error(\n            'colorCount should be between 2 and 20. To get one color, call getColor() instead of getPalette()',\n        );\n    } else {\n        colorCount = Math.max(colorCount, 2);\n        colorCount = Math.min(colorCount, 20);\n    }\n\n    if (\n        typeof quality === 'undefined' ||\n        !Number.isInteger(quality) ||\n        quality < 1\n    ) {\n        quality = 10;\n    }\n\n    const ignoreWhite =\n        options.ignoreWhite !== undefined ? !!options.ignoreWhite : true;\n    const whiteThreshold =\n        typeof options.whiteThreshold === 'number' ? options.whiteThreshold : 250;\n    const alphaThreshold =\n        typeof options.alphaThreshold === 'number' ? options.alphaThreshold : 125;\n    const minSaturation =\n        typeof options.minSaturation === 'number'\n            ? Math.max(0, Math.min(1, options.minSaturation))\n            : 0;\n    const colorSpace = options.colorSpace ?? 'oklch';\n\n    return {\n        colorCount,\n        quality,\n        ignoreWhite,\n        whiteThreshold,\n        alphaThreshold,\n        minSaturation,\n        colorSpace,\n    };\n}\n\n// ---------------------------------------------------------------------------\n// Pixel sampling\n// ---------------------------------------------------------------------------\n\nexport function createPixelArray(\n    data: PixelBuffer,\n    pixelCount: number,\n    quality: number,\n    filterOptions: FilterOptions,\n): Array<[number, number, number]> {\n    const {\n        ignoreWhite = true,\n        whiteThreshold = 250,\n        alphaThreshold = 125,\n        minSaturation = 0,\n    } = filterOptions;\n\n    const pixelArray: Array<[number, number, number]> = [];\n\n    for (let i = 0; i < pixelCount; i += quality) {\n        const offset = i * 4;\n        const r = data[offset];\n        const g = data[offset + 1];\n        const b = data[offset + 2];\n        const a = data[offset + 3];\n\n        // Skip transparent pixels\n        if (a !== undefined && a < alphaThreshold) continue;\n\n        // Skip white pixels\n        if (\n            ignoreWhite &&\n            r > whiteThreshold &&\n            g > whiteThreshold &&\n            b > whiteThreshold\n        )\n            continue;\n\n        // Skip low-saturation pixels\n        if (minSaturation > 0) {\n            const max = Math.max(r, g, b);\n            if (max === 0 || (max - Math.min(r, g, b)) / max < minSaturation)\n                continue;\n        }\n\n        pixelArray.push([r, g, b]);\n    }\n\n    return pixelArray;\n}\n\n// ---------------------------------------------------------------------------\n// Fallback color (average)\n// ---------------------------------------------------------------------------\n\nexport function computeFallbackColor(\n    data: PixelBuffer,\n    pixelCount: number,\n    quality: number,\n): [number, number, number] | null {\n    let rTotal = 0;\n    let gTotal = 0;\n    let bTotal = 0;\n    let count = 0;\n\n    for (let i = 0; i < pixelCount; i += quality) {\n        const offset = i * 4;\n        rTotal += data[offset];\n        gTotal += data[offset + 1];\n        bTotal += data[offset + 2];\n        count++;\n    }\n\n    if (count === 0) return null;\n\n    return [\n        Math.round(rTotal / count),\n        Math.round(gTotal / count),\n        Math.round(bTotal / count),\n    ];\n}\n\n// ---------------------------------------------------------------------------\n// Main extraction pipeline\n// ---------------------------------------------------------------------------\n\nexport function extractPalette(\n    data: PixelBuffer,\n    width: number,\n    height: number,\n    opts: ValidatedOptions,\n    quantizer: Quantizer,\n): Color[] | null {\n    const pixelCount = width * height;\n    const filterOptions: FilterOptions = {\n        ignoreWhite: opts.ignoreWhite,\n        whiteThreshold: opts.whiteThreshold,\n        alphaThreshold: opts.alphaThreshold,\n        minSaturation: opts.minSaturation,\n    };\n\n    let pixelArray = createPixelArray(data, pixelCount, opts.quality, filterOptions);\n\n    // Progressively relax filters if all pixels were excluded\n    if (pixelArray.length === 0) {\n        pixelArray = createPixelArray(data, pixelCount, opts.quality, {\n            ...filterOptions,\n            ignoreWhite: false,\n        });\n    }\n    if (pixelArray.length === 0) {\n        pixelArray = createPixelArray(data, pixelCount, opts.quality, {\n            ...filterOptions,\n            ignoreWhite: false,\n            alphaThreshold: 0,\n        });\n    }\n\n    // OKLCH quantization path\n    let quantized: Array<{ color: [number, number, number]; population: number }>;\n    if (opts.colorSpace === 'oklch') {\n        const scaled = pixelsRgbToOklchScaled(pixelArray);\n        quantized = paletteOklchScaledToRgb(\n            quantizer.quantize(scaled, opts.colorCount),\n        );\n    } else {\n        quantized = quantizer.quantize(pixelArray, opts.colorCount);\n    }\n\n    if (quantized.length > 0) {\n        const totalPopulation = quantized.reduce((sum, q) => sum + q.population, 0);\n        return quantized.map(({ color: [r, g, b], population }) =>\n            createColor(r, g, b, population, totalPopulation > 0 ? population / totalPopulation : 0),\n        );\n    }\n\n    // Fallback: average all pixels\n    const fallback = computeFallbackColor(data, pixelCount, opts.quality);\n    return fallback ? [createColor(fallback[0], fallback[1], fallback[2], 1, 1)] : null;\n}\n"
  },
  {
    "path": "src/progressive.ts",
    "content": "import type {\n    Color,\n    PixelBuffer,\n    ProgressiveResult,\n    Quantizer,\n} from './types.js';\nimport { extractPalette, type ValidatedOptions } from './pipeline.js';\n\n/** Quality divisors for the 3 progressive passes. */\nconst PASSES = [\n    { divisor: 16, progress: 0.06 },\n    { divisor: 4, progress: 0.25 },\n    { divisor: 1, progress: 1.0 },\n];\n\n/** Yield between passes so the UI can repaint. */\nfunction yieldToMain(): Promise<void> {\n    return new Promise((resolve) => setTimeout(resolve, 0));\n}\n\n/**\n * Progressive palette extraction. Runs 3 passes with increasing quality\n * (16x skip → 4x skip → full quality), yielding intermediate results.\n */\nexport async function* extractProgressive(\n    data: PixelBuffer,\n    width: number,\n    height: number,\n    opts: ValidatedOptions,\n    quantizer: Quantizer,\n    signal?: AbortSignal,\n): AsyncGenerator<ProgressiveResult> {\n    for (let i = 0; i < PASSES.length; i++) {\n        if (signal?.aborted) {\n            throw signal.reason ?? new DOMException('Aborted', 'AbortError');\n        }\n\n        const pass = PASSES[i];\n        const passOpts: ValidatedOptions = {\n            ...opts,\n            quality: opts.quality * pass.divisor,\n        };\n\n        const palette = extractPalette(data, width, height, passOpts, quantizer);\n        const done = i === PASSES.length - 1;\n\n        yield {\n            palette: palette ?? [],\n            progress: pass.progress,\n            done,\n        };\n\n        if (!done) {\n            await yieldToMain();\n        }\n    }\n}\n"
  },
  {
    "path": "src/quantizers/mmcq.ts",
    "content": "import type { Quantizer } from '../types.js';\n\n// ---------------------------------------------------------------------------\n// Constants (match original quantize library)\n// ---------------------------------------------------------------------------\n\nconst SIGBITS = 5;\nconst RSHIFT = 8 - SIGBITS;\nconst MAX_ITERATIONS = 1000;\nconst FRACT_BY_POPULATIONS = 0.75;\nconst HISTO_SIZE = 1 << (3 * SIGBITS);\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction getColorIndex(r: number, g: number, b: number): number {\n    return (r << (2 * SIGBITS)) + (g << SIGBITS) + b;\n}\n\n// ---------------------------------------------------------------------------\n// VBox — a 3-D box in reduced (5-bit) RGB color space\n// ---------------------------------------------------------------------------\n\nclass VBox {\n    r1: number;\n    r2: number;\n    g1: number;\n    g2: number;\n    b1: number;\n    b2: number;\n\n    private readonly histo: Uint32Array;\n    private _volume: number | undefined;\n    private _count: number | undefined;\n    private _avg: [number, number, number] | undefined;\n\n    constructor(\n        r1: number,\n        r2: number,\n        g1: number,\n        g2: number,\n        b1: number,\n        b2: number,\n        histo: Uint32Array,\n    ) {\n        this.r1 = r1;\n        this.r2 = r2;\n        this.g1 = g1;\n        this.g2 = g2;\n        this.b1 = b1;\n        this.b2 = b2;\n        this.histo = histo;\n    }\n\n    volume(force = false): number {\n        if (this._volume === undefined || force) {\n            this._volume =\n                (this.r2 - this.r1 + 1) *\n                (this.g2 - this.g1 + 1) *\n                (this.b2 - this.b1 + 1);\n        }\n        return this._volume;\n    }\n\n    count(force = false): number {\n        if (this._count === undefined || force) {\n            let npix = 0;\n            for (let i = this.r1; i <= this.r2; i++) {\n                for (let j = this.g1; j <= this.g2; j++) {\n                    for (let k = this.b1; k <= this.b2; k++) {\n                        npix += this.histo[getColorIndex(i, j, k)] || 0;\n                    }\n                }\n            }\n            this._count = npix;\n        }\n        return this._count;\n    }\n\n    copy(): VBox {\n        return new VBox(this.r1, this.r2, this.g1, this.g2, this.b1, this.b2, this.histo);\n    }\n\n    avg(force = false): [number, number, number] {\n        if (this._avg === undefined || force) {\n            const mult = 1 << RSHIFT;\n\n            // Single-color box: return exact color\n            if (this.r1 === this.r2 && this.g1 === this.g2 && this.b1 === this.b2) {\n                this._avg = [\n                    this.r1 << RSHIFT,\n                    this.g1 << RSHIFT,\n                    this.b1 << RSHIFT,\n                ];\n            } else {\n                let ntot = 0;\n                let rsum = 0;\n                let gsum = 0;\n                let bsum = 0;\n\n                for (let i = this.r1; i <= this.r2; i++) {\n                    for (let j = this.g1; j <= this.g2; j++) {\n                        for (let k = this.b1; k <= this.b2; k++) {\n                            const hval = this.histo[getColorIndex(i, j, k)] || 0;\n                            ntot += hval;\n                            rsum += hval * (i + 0.5) * mult;\n                            gsum += hval * (j + 0.5) * mult;\n                            bsum += hval * (k + 0.5) * mult;\n                        }\n                    }\n                }\n\n                if (ntot) {\n                    this._avg = [\n                        ~~(rsum / ntot),\n                        ~~(gsum / ntot),\n                        ~~(bsum / ntot),\n                    ];\n                } else {\n                    this._avg = [\n                        ~~((mult * (this.r1 + this.r2 + 1)) / 2),\n                        ~~((mult * (this.g1 + this.g2 + 1)) / 2),\n                        ~~((mult * (this.b1 + this.b2 + 1)) / 2),\n                    ];\n                }\n            }\n        }\n        return this._avg;\n    }\n}\n\n// ---------------------------------------------------------------------------\n// PQueue — lazy-sorted priority queue\n// ---------------------------------------------------------------------------\n\nclass PQueue<T> {\n    private contents: T[] = [];\n    private sorted = false;\n\n    constructor(private comparator: (a: T, b: T) => number) {}\n\n    private sort(): void {\n        this.contents.sort(this.comparator);\n        this.sorted = true;\n    }\n\n    push(item: T): void {\n        this.contents.push(item);\n        this.sorted = false;\n    }\n\n    peek(index?: number): T {\n        if (!this.sorted) this.sort();\n        return this.contents[index ?? this.contents.length - 1];\n    }\n\n    pop(): T {\n        if (!this.sorted) this.sort();\n        return this.contents.pop()!;\n    }\n\n    size(): number {\n        return this.contents.length;\n    }\n\n    map<U>(fn: (item: T) => U): U[] {\n        return this.contents.map(fn);\n    }\n}\n\n// ---------------------------------------------------------------------------\n// Histogram & initial VBox\n// ---------------------------------------------------------------------------\n\nfunction getHisto(pixels: Array<[number, number, number]>): Uint32Array {\n    const histo = new Uint32Array(HISTO_SIZE);\n    for (const pixel of pixels) {\n        const rval = pixel[0] >> RSHIFT;\n        const gval = pixel[1] >> RSHIFT;\n        const bval = pixel[2] >> RSHIFT;\n        histo[getColorIndex(rval, gval, bval)]++;\n    }\n    return histo;\n}\n\nfunction vboxFromPixels(\n    pixels: Array<[number, number, number]>,\n    histo: Uint32Array,\n): VBox {\n    let rmin = 1000000;\n    let rmax = 0;\n    let gmin = 1000000;\n    let gmax = 0;\n    let bmin = 1000000;\n    let bmax = 0;\n\n    for (const pixel of pixels) {\n        const rval = pixel[0] >> RSHIFT;\n        const gval = pixel[1] >> RSHIFT;\n        const bval = pixel[2] >> RSHIFT;\n        if (rval < rmin) rmin = rval;\n        else if (rval > rmax) rmax = rval;\n        if (gval < gmin) gmin = gval;\n        else if (gval > gmax) gmax = gval;\n        if (bval < bmin) bmin = bval;\n        else if (bval > bmax) bmax = bval;\n    }\n\n    return new VBox(rmin, rmax, gmin, gmax, bmin, bmax, histo);\n}\n\n// ---------------------------------------------------------------------------\n// Median-cut split\n// ---------------------------------------------------------------------------\n\nfunction medianCutApply(histo: Uint32Array, vbox: VBox): [VBox, VBox | null] | undefined {\n    if (!vbox.count()) return undefined;\n\n    // Only one pixel — no split possible\n    if (vbox.count() === 1) return [vbox.copy(), null];\n\n    const rw = vbox.r2 - vbox.r1 + 1;\n    const gw = vbox.g2 - vbox.g1 + 1;\n    const bw = vbox.b2 - vbox.b1 + 1;\n    const maxw = Math.max(rw, gw, bw);\n\n    let total = 0;\n    const partialsum: number[] = [];\n    const lookaheadsum: number[] = [];\n\n    if (maxw === rw) {\n        for (let i = vbox.r1; i <= vbox.r2; i++) {\n            let sum = 0;\n            for (let j = vbox.g1; j <= vbox.g2; j++) {\n                for (let k = vbox.b1; k <= vbox.b2; k++) {\n                    sum += histo[getColorIndex(i, j, k)] || 0;\n                }\n            }\n            total += sum;\n            partialsum[i] = total;\n        }\n    } else if (maxw === gw) {\n        for (let i = vbox.g1; i <= vbox.g2; i++) {\n            let sum = 0;\n            for (let j = vbox.r1; j <= vbox.r2; j++) {\n                for (let k = vbox.b1; k <= vbox.b2; k++) {\n                    sum += histo[getColorIndex(j, i, k)] || 0;\n                }\n            }\n            total += sum;\n            partialsum[i] = total;\n        }\n    } else {\n        for (let i = vbox.b1; i <= vbox.b2; i++) {\n            let sum = 0;\n            for (let j = vbox.r1; j <= vbox.r2; j++) {\n                for (let k = vbox.g1; k <= vbox.g2; k++) {\n                    sum += histo[getColorIndex(j, k, i)] || 0;\n                }\n            }\n            total += sum;\n            partialsum[i] = total;\n        }\n    }\n\n    partialsum.forEach((d, i) => {\n        lookaheadsum[i] = total - d;\n    });\n\n    function doCut(color: 'r' | 'g' | 'b'): [VBox, VBox] | undefined {\n        const dim1 = (color + '1') as 'r1' | 'g1' | 'b1';\n        const dim2 = (color + '2') as 'r2' | 'g2' | 'b2';\n\n        for (let i = vbox[dim1]; i <= vbox[dim2]; i++) {\n            if (partialsum[i] > total / 2) {\n                const vbox1 = vbox.copy();\n                const vbox2 = vbox.copy();\n                const left = i - vbox[dim1];\n                const right = vbox[dim2] - i;\n\n                let d2: number;\n                if (left <= right) {\n                    d2 = Math.min(vbox[dim2] - 1, ~~(i + right / 2));\n                } else {\n                    d2 = Math.max(vbox[dim1], ~~(i - 1 - left / 2));\n                }\n\n                // Avoid 0-count boxes\n                while (!partialsum[d2]) d2++;\n                let count2 = lookaheadsum[d2];\n                while (!count2 && partialsum[d2 - 1]) count2 = lookaheadsum[--d2];\n\n                // Set dimensions\n                vbox1[dim2] = d2;\n                vbox2[dim1] = vbox1[dim2] + 1;\n\n                return [vbox1, vbox2];\n            }\n        }\n        return undefined;\n    }\n\n    if (maxw === rw) return doCut('r');\n    if (maxw === gw) return doCut('g');\n    return doCut('b');\n}\n\n// ---------------------------------------------------------------------------\n// Iterative splitting\n// ---------------------------------------------------------------------------\n\nfunction iterate(pq: PQueue<VBox>, target: number, histo: Uint32Array): void {\n    let ncolors = pq.size();\n    let niters = 0;\n\n    while (niters < MAX_ITERATIONS) {\n        if (ncolors >= target) return;\n        niters++;\n\n        const vbox = pq.pop();\n\n        if (!vbox.count()) {\n            pq.push(vbox);\n            continue;\n        }\n\n        const result = medianCutApply(histo, vbox);\n        if (!result || !result[0]) return;\n\n        pq.push(result[0]);\n        if (result[1]) {\n            pq.push(result[1]);\n            ncolors++;\n        }\n    }\n}\n\n// ---------------------------------------------------------------------------\n// Main quantize function\n// ---------------------------------------------------------------------------\n\nfunction quantize(\n    pixels: Array<[number, number, number]>,\n    maxColors: number,\n): Array<{ color: [number, number, number]; population: number }> {\n    if (!pixels.length || maxColors < 2 || maxColors > 256) return [];\n\n    // Short-circuit: if unique colors <= maxColors, return them directly\n    const seenColors = new Set<string>();\n    const uniqueColors: Array<[number, number, number]> = [];\n    for (const color of pixels) {\n        const key = color.join(',');\n        if (!seenColors.has(key)) {\n            seenColors.add(key);\n            uniqueColors.push(color);\n        }\n    }\n    if (uniqueColors.length <= maxColors) {\n        // Count populations for unique colors\n        const countMap = new Map<string, number>();\n        for (const color of pixels) {\n            const key = color.join(',');\n            countMap.set(key, (countMap.get(key) || 0) + 1);\n        }\n        return uniqueColors.map((color) => ({\n            color,\n            population: countMap.get(color.join(','))!,\n        }));\n    }\n\n    const histo = getHisto(pixels);\n\n    // Get the initial vbox from the pixels\n    const vbox = vboxFromPixels(pixels, histo);\n    const pq = new PQueue<VBox>((a, b) => a.count() - b.count());\n    pq.push(vbox);\n\n    // Phase 1: split by population until FRACT_BY_POPULATIONS * maxColors\n    iterate(pq, FRACT_BY_POPULATIONS * maxColors, histo);\n\n    // Phase 2: re-sort by count * volume, continue splitting\n    const pq2 = new PQueue<VBox>((a, b) => a.count() * a.volume() - b.count() * b.volume());\n    while (pq.size()) {\n        pq2.push(pq.pop());\n    }\n    iterate(pq2, maxColors, histo);\n\n    // Extract palette with population counts\n    const results: Array<{ color: [number, number, number]; population: number }> = [];\n    while (pq2.size()) {\n        const box = pq2.pop();\n        results.push({\n            color: box.avg(),\n            population: box.count(),\n        });\n    }\n\n    return results;\n}\n\n// ---------------------------------------------------------------------------\n// Quantizer adapter\n// ---------------------------------------------------------------------------\n\n/**\n * MMCQ (Modified Median Cut Quantization) — inlined TypeScript implementation.\n * Port of the @lokesh.dhakar/quantize algorithm with population tracking.\n */\nexport class MmcqQuantizer implements Quantizer {\n    async init(): Promise<void> {\n        // No-op — pure TypeScript, ready to use.\n    }\n\n    quantize(\n        pixels: Array<[number, number, number]>,\n        maxColors: number,\n    ): Array<{ color: [number, number, number]; population: number }> {\n        return quantize(pixels, maxColors);\n    }\n}\n"
  },
  {
    "path": "src/quantizers/wasm.ts",
    "content": "import type { Quantizer } from '../types.js';\n\n/**\n * WASM-based MMCQ quantizer. Loads the WASM module built from Rust.\n *\n * Build the WASM module first:\n *   cd src/wasm && wasm-pack build --target web --out-dir ../../dist/wasm\n *\n * Usage:\n * ```ts\n * import { configure, WasmQuantizer } from 'colorthief';\n * const q = new WasmQuantizer();\n * await q.init();\n * configure({ quantizer: q });\n * ```\n */\nexport class WasmQuantizer implements Quantizer {\n    private wasmQuantize: ((pixels: Uint8Array, maxColors: number) => Uint8Array) | null = null;\n    private wasmUrl: string | URL | undefined;\n\n    /**\n     * @param wasmUrl - Optional URL to the .wasm file. If not provided,\n     *                  attempts to load from the default dist location.\n     */\n    constructor(wasmUrl?: string | URL) {\n        this.wasmUrl = wasmUrl;\n    }\n\n    async init(): Promise<void> {\n        if (this.wasmQuantize) return;\n\n        // Try to dynamically import the wasm-bindgen generated JS glue\n        try {\n            // eslint-disable-next-line @typescript-eslint/no-explicit-any\n            let wasm: any;\n            if (this.wasmUrl) {\n                // For environments where the wasm file needs explicit loading\n                const response = await fetch(this.wasmUrl);\n                const bytes = await response.arrayBuffer();\n                const module = await WebAssembly.compile(bytes);\n                const instance = await WebAssembly.instantiate(module);\n                wasm = instance.exports;\n            } else {\n                // Default: try importing the wasm-pack output\n                wasm = await import('../../dist/wasm/color_thief_wasm.js' as string);\n                if (wasm.default && typeof wasm.default === 'function') {\n                    await wasm.default();\n                }\n            }\n            this.wasmQuantize = wasm.quantize;\n        } catch (e) {\n            throw new Error(\n                `Failed to initialize WASM quantizer: ${e instanceof Error ? e.message : String(e)}`,\n            );\n        }\n    }\n\n    quantize(\n        pixels: Array<[number, number, number]>,\n        maxColors: number,\n    ): Array<{ color: [number, number, number]; population: number }> {\n        if (!this.wasmQuantize) {\n            throw new Error('WasmQuantizer.init() must be called before quantize()');\n        }\n\n        // Flatten pixels into a flat Uint8Array [r,g,b,r,g,b,...]\n        const flat = new Uint8Array(pixels.length * 3);\n        for (let i = 0; i < pixels.length; i++) {\n            flat[i * 3] = pixels[i][0];\n            flat[i * 3 + 1] = pixels[i][1];\n            flat[i * 3 + 2] = pixels[i][2];\n        }\n\n        const resultBytes = this.wasmQuantize(flat, maxColors);\n\n        // Parse result: 7 bytes per color (r, g, b, pop_le[4])\n        const results: Array<{ color: [number, number, number]; population: number }> = [];\n        const view = new DataView(resultBytes.buffer, resultBytes.byteOffset, resultBytes.byteLength);\n\n        for (let offset = 0; offset + 6 < resultBytes.length; offset += 7) {\n            const r = resultBytes[offset];\n            const g = resultBytes[offset + 1];\n            const b = resultBytes[offset + 2];\n            const population = view.getUint32(offset + 3, true); // little-endian\n            results.push({ color: [r, g, b], population });\n        }\n\n        return results;\n    }\n}\n"
  },
  {
    "path": "src/resolve-loader.browser.ts",
    "content": "import type { ImageSource, PixelLoader } from './types.js';\n\n/**\n * Resolve the default pixel loader — browser-only version.\n * This module is substituted for resolve-loader.ts in browser builds\n * so that bundlers never see the sharp dependency.\n */\nexport async function resolveDefaultLoader(): Promise<PixelLoader<ImageSource>> {\n    const { BrowserPixelLoader } = await import('./loaders/browser.js');\n    return new BrowserPixelLoader() as PixelLoader<ImageSource>;\n}\n"
  },
  {
    "path": "src/resolve-loader.ts",
    "content": "import type { ImageSource, PixelLoader } from './types.js';\n\n/**\n * Resolve the default pixel loader based on the current environment.\n * This universal version supports both browser and Node.js.\n *\n * The browser build swaps this module for resolve-loader.browser.ts\n * which only includes the browser loader (no sharp dependency).\n */\nexport async function resolveDefaultLoader(): Promise<PixelLoader<ImageSource>> {\n    const isBrowser =\n        typeof window !== 'undefined' && typeof document !== 'undefined';\n\n    if (isBrowser) {\n        const { BrowserPixelLoader } = await import('./loaders/browser.js');\n        return new BrowserPixelLoader() as PixelLoader<ImageSource>;\n    } else {\n        const { NodePixelLoader } = await import('./loaders/node.js');\n        return new NodePixelLoader() as PixelLoader<ImageSource>;\n    }\n}\n"
  },
  {
    "path": "src/swatches.ts",
    "content": "import type { Color, Swatch, SwatchMap, SwatchRole } from './types.js';\nimport { createColor } from './color.js';\n\n// ---------------------------------------------------------------------------\n// OKLCH target ranges for each swatch role\n// ---------------------------------------------------------------------------\n\ninterface SwatchTarget {\n    role: SwatchRole;\n    /** Target OKLCH lightness (0–1). */\n    targetL: number;\n    /** Min / max lightness. */\n    minL: number;\n    maxL: number;\n    /** Target chroma (0–0.4). */\n    targetC: number;\n    /** Min chroma. */\n    minC: number;\n}\n\nconst TARGETS: SwatchTarget[] = [\n    { role: 'Vibrant',      targetL: 0.65, minL: 0.40, maxL: 0.85, targetC: 0.20, minC: 0.08 },\n    { role: 'Muted',        targetL: 0.65, minL: 0.40, maxL: 0.85, targetC: 0.04, minC: 0.00 },\n    { role: 'DarkVibrant',  targetL: 0.30, minL: 0.00, maxL: 0.45, targetC: 0.20, minC: 0.08 },\n    { role: 'DarkMuted',    targetL: 0.30, minL: 0.00, maxL: 0.45, targetC: 0.04, minC: 0.00 },\n    { role: 'LightVibrant', targetL: 0.85, minL: 0.70, maxL: 1.00, targetC: 0.20, minC: 0.08 },\n    { role: 'LightMuted',   targetL: 0.85, minL: 0.70, maxL: 1.00, targetC: 0.04, minC: 0.00 },\n];\n\n// ---------------------------------------------------------------------------\n// Scoring\n// ---------------------------------------------------------------------------\n\nconst WEIGHT_L = 6;\nconst WEIGHT_C = 3;\nconst WEIGHT_POP = 1;\n\nfunction score(\n    color: Color,\n    target: SwatchTarget,\n    maxPopulation: number,\n): number {\n    const { l, c } = color.oklch();\n\n    // Out of lightness range → disqualified\n    if (l < target.minL || l > target.maxL) return -Infinity;\n    // Below minimum chroma → disqualified\n    if (c < target.minC) return -Infinity;\n\n    const lDist = 1 - Math.abs(l - target.targetL);\n    const cDist = 1 - Math.min(Math.abs(c - target.targetC) / 0.2, 1);\n    const pop = maxPopulation > 0 ? color.population / maxPopulation : 0;\n\n    return lDist * WEIGHT_L + cDist * WEIGHT_C + pop * WEIGHT_POP;\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\nconst WHITE = createColor(255, 255, 255, 0);\nconst BLACK = createColor(0, 0, 0, 0);\n\nfunction textColors(color: Color): { title: Color; body: Color } {\n    return {\n        title: color.isDark ? WHITE : BLACK,\n        body: color.isDark ? WHITE : BLACK,\n    };\n}\n\n/**\n * Classify a palette into semantic swatch roles using OKLCH distance scoring.\n * Each role is matched to the best-scoring palette color. A color can only\n * be assigned to one role (the one where it scores highest).\n */\nexport function classifySwatches(palette: Color[]): SwatchMap {\n    const maxPopulation = Math.max(...palette.map((c) => c.population), 1);\n\n    // Score every (color, target) pair\n    const assignments: Array<{\n        role: SwatchRole;\n        color: Color;\n        score: number;\n    }> = [];\n\n    for (const target of TARGETS) {\n        let bestColor: Color | null = null;\n        let bestScore = -Infinity;\n\n        for (const color of palette) {\n            const s = score(color, target, maxPopulation);\n            if (s > bestScore) {\n                bestScore = s;\n                bestColor = color;\n            }\n        }\n\n        if (bestColor && bestScore > -Infinity) {\n            assignments.push({ role: target.role, color: bestColor, score: bestScore });\n        }\n    }\n\n    // Resolve conflicts: if the same color is best for multiple roles,\n    // keep the role where it scored highest; re-pick the loser.\n    const used = new Set<Color>();\n    const result: Partial<SwatchMap> = {};\n\n    // Sort assignments by score descending so highest-scoring role wins\n    assignments.sort((a, b) => b.score - a.score);\n\n    for (const assignment of assignments) {\n        if (used.has(assignment.color)) {\n            // Try to find next best unused color for this role\n            const target = TARGETS.find((t) => t.role === assignment.role)!;\n            let fallback: Color | null = null;\n            let fallbackScore = -Infinity;\n            for (const color of palette) {\n                if (used.has(color)) continue;\n                const s = score(color, target, maxPopulation);\n                if (s > fallbackScore) {\n                    fallbackScore = s;\n                    fallback = color;\n                }\n            }\n            if (fallback && fallbackScore > -Infinity) {\n                used.add(fallback);\n                const { title, body } = textColors(fallback);\n                result[assignment.role] = {\n                    color: fallback,\n                    role: assignment.role,\n                    titleTextColor: title,\n                    bodyTextColor: body,\n                };\n            } else {\n                result[assignment.role] = null;\n            }\n        } else {\n            used.add(assignment.color);\n            const { title, body } = textColors(assignment.color);\n            result[assignment.role] = {\n                color: assignment.color,\n                role: assignment.role,\n                titleTextColor: title,\n                bodyTextColor: body,\n            };\n        }\n    }\n\n    // Fill any unassigned roles with null\n    for (const target of TARGETS) {\n        if (!(target.role in result)) {\n            result[target.role] = null;\n        }\n    }\n\n    return result as SwatchMap;\n}\n"
  },
  {
    "path": "src/sync.ts",
    "content": "/**\n * Synchronous browser-only API.\n *\n * These functions accept only BrowserSource (HTMLImageElement, HTMLCanvasElement,\n * ImageData, ImageBitmap) and run entirely on the main thread with no async overhead.\n *\n * For Node.js sources (file paths, Buffers) or features like Web Workers and\n * AbortSignal, use the async API (getColor, getPalette, getSwatches).\n */\nimport type {\n    BrowserSource,\n    Color,\n    FilterOptions,\n    ColorSpace,\n    Quantizer,\n    SwatchMap,\n} from './types.js';\nimport { BrowserPixelLoader } from './loaders/browser.js';\nimport { MmcqQuantizer } from './quantizers/mmcq.js';\nimport { validateOptions, extractPalette } from './pipeline.js';\nimport { classifySwatches } from './swatches.js';\n\n// ---------------------------------------------------------------------------\n// Sync-specific options (subset — no worker, no signal, no loader)\n// ---------------------------------------------------------------------------\n\nexport interface SyncExtractionOptions extends FilterOptions {\n    /** Number of colors in the palette (2–20). @default 10 */\n    colorCount?: number;\n    /** Sampling quality (1 = highest). @default 10 */\n    quality?: number;\n    /** Color space for quantization. @default 'rgb' */\n    colorSpace?: ColorSpace;\n    /** Override the quantizer for this call. Must already be init()'d. */\n    quantizer?: Quantizer;\n}\n\n// ---------------------------------------------------------------------------\n// Shared singletons\n// ---------------------------------------------------------------------------\n\nconst loader = new BrowserPixelLoader();\nconst defaultQuantizer = new MmcqQuantizer();\n\n// ---------------------------------------------------------------------------\n// Public sync API\n// ---------------------------------------------------------------------------\n\n/**\n * Synchronously get the dominant color from a browser image source.\n *\n * ```ts\n * const color = getColorSync(imgElement);\n * console.log(color.hex()); // '#e84393'\n * ```\n */\nexport function getColorSync(\n    source: BrowserSource,\n    options?: SyncExtractionOptions,\n): Color | null {\n    const palette = getPaletteSync(source, { colorCount: 5, ...options });\n    return palette ? palette[0] : null;\n}\n\n/**\n * Synchronously get a color palette from a browser image source.\n *\n * ```ts\n * const palette = getPaletteSync(imgElement, { colorCount: 5 });\n * palette.forEach(c => console.log(c.hex()));\n * ```\n */\nexport function getPaletteSync(\n    source: BrowserSource,\n    options?: SyncExtractionOptions,\n): Color[] | null {\n    const opts = validateOptions(options ?? {});\n    const quantizer = options?.quantizer ?? defaultQuantizer;\n\n    // BrowserPixelLoader.load is async in signature but synchronous in practice\n    // for HTMLImageElement/Canvas/ImageData/ImageBitmap. We call the internal\n    // methods directly to avoid the Promise wrapper.\n    const pixels = loadPixelsSync(source);\n\n    return extractPalette(\n        pixels.data,\n        pixels.width,\n        pixels.height,\n        opts,\n        quantizer,\n    );\n}\n\n/**\n * Synchronously get semantic swatches from a browser image source.\n *\n * ```ts\n * const swatches = getSwatchesSync(imgElement);\n * console.log(swatches.Vibrant?.color.hex());\n * ```\n */\nexport function getSwatchesSync(\n    source: BrowserSource,\n    options?: SyncExtractionOptions,\n): SwatchMap {\n    const palette = getPaletteSync(source, { colorCount: 16, ...options });\n    return classifySwatches(palette ?? []);\n}\n\n// ---------------------------------------------------------------------------\n// Internal sync pixel loading\n// ---------------------------------------------------------------------------\n\nfunction loadPixelsSync(source: BrowserSource) {\n    if (typeof HTMLImageElement !== 'undefined' && source instanceof HTMLImageElement) {\n        return loadFromImage(source);\n    }\n    if (typeof HTMLCanvasElement !== 'undefined' && source instanceof HTMLCanvasElement) {\n        return loadFromCanvas(source);\n    }\n    if (typeof ImageData !== 'undefined' && source instanceof ImageData) {\n        return { data: source.data, width: source.width, height: source.height };\n    }\n    if (typeof HTMLVideoElement !== 'undefined' && source instanceof HTMLVideoElement) {\n        return loadFromVideo(source);\n    }\n    if (typeof ImageBitmap !== 'undefined' && source instanceof ImageBitmap) {\n        return loadFromImageBitmap(source);\n    }\n    if (typeof OffscreenCanvas !== 'undefined' && source instanceof OffscreenCanvas) {\n        return loadFromOffscreenCanvas(source);\n    }\n    throw new Error(\n        'Unsupported source type. Expected HTMLImageElement, HTMLCanvasElement, HTMLVideoElement, ImageData, ImageBitmap, or OffscreenCanvas.',\n    );\n}\n\nfunction loadFromImage(img: HTMLImageElement) {\n    if (!img.complete) {\n        throw new Error(\n            'Image has not finished loading. Wait for the \"load\" event before calling getColorSync/getPaletteSync.',\n        );\n    }\n    if (!img.naturalWidth) {\n        throw new Error(\n            'Image has no dimensions. It may not have loaded successfully.',\n        );\n    }\n    const canvas = document.createElement('canvas');\n    const ctx = canvas.getContext('2d')!;\n    const width = (canvas.width = img.naturalWidth);\n    const height = (canvas.height = img.naturalHeight);\n    ctx.drawImage(img, 0, 0, width, height);\n    try {\n        const imageData = ctx.getImageData(0, 0, width, height);\n        return { data: imageData.data, width, height };\n    } catch (e: unknown) {\n        if (e instanceof DOMException && e.name === 'SecurityError') {\n            const err = new Error(\n                'Image is tainted by cross-origin data. Add crossorigin=\"anonymous\" to the <img> tag and ensure the server sends appropriate CORS headers.',\n            );\n            err.cause = e;\n            throw err;\n        }\n        throw e;\n    }\n}\n\nfunction loadFromCanvas(canvas: HTMLCanvasElement) {\n    const ctx = canvas.getContext('2d')!;\n    const { width, height } = canvas;\n    const imageData = ctx.getImageData(0, 0, width, height);\n    return { data: imageData.data, width, height };\n}\n\nfunction loadFromVideo(video: HTMLVideoElement) {\n    if (video.readyState < 2) {\n        throw new Error(\n            'Video is not ready. Wait for the \"loadeddata\" or \"canplay\" event before calling getColorSync/getPaletteSync.',\n        );\n    }\n    const width = video.videoWidth;\n    const height = video.videoHeight;\n    if (!width || !height) {\n        throw new Error(\n            'Video has no dimensions. It may not have loaded successfully.',\n        );\n    }\n    const canvas = document.createElement('canvas');\n    const ctx = canvas.getContext('2d')!;\n    canvas.width = width;\n    canvas.height = height;\n    ctx.drawImage(video, 0, 0, width, height);\n    const imageData = ctx.getImageData(0, 0, width, height);\n    return { data: imageData.data, width, height };\n}\n\nfunction loadFromOffscreenCanvas(canvas: OffscreenCanvas) {\n    const ctx = canvas.getContext('2d') as OffscreenCanvasRenderingContext2D;\n    if (!ctx) {\n        throw new Error(\n            'Could not get 2D context from OffscreenCanvas.',\n        );\n    }\n    const { width, height } = canvas;\n    const imageData = ctx.getImageData(0, 0, width, height);\n    return { data: imageData.data, width, height };\n}\n\nfunction loadFromImageBitmap(bitmap: ImageBitmap) {\n    const canvas = document.createElement('canvas');\n    const ctx = canvas.getContext('2d')!;\n    canvas.width = bitmap.width;\n    canvas.height = bitmap.height;\n    ctx.drawImage(bitmap, 0, 0);\n    const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);\n    return { data: imageData.data, width: bitmap.width, height: bitmap.height };\n}\n"
  },
  {
    "path": "src/types.ts",
    "content": "// ---------------------------------------------------------------------------\n// Color spaces\n// ---------------------------------------------------------------------------\n\n/** Red, Green, Blue — each channel 0–255. */\nexport interface RGB {\n    r: number;\n    g: number;\n    b: number;\n}\n\n/** Hue (0–360), Saturation (0–100), Lightness (0–100). */\nexport interface HSL {\n    h: number;\n    s: number;\n    l: number;\n}\n\n/** OKLCH perceptual color space — Lightness (0–1), Chroma (0–0.4), Hue (0–360). */\nexport interface OKLCH {\n    l: number;\n    c: number;\n    h: number;\n}\n\n// ---------------------------------------------------------------------------\n// Pixel data\n// ---------------------------------------------------------------------------\n\n/** Raw RGBA pixel buffer (Uint8Array or Uint8ClampedArray). */\nexport type PixelBuffer = Uint8Array | Uint8ClampedArray;\n\n/** Decoded pixel data with dimensions. */\nexport interface PixelData {\n    data: PixelBuffer;\n    width: number;\n    height: number;\n}\n\n// ---------------------------------------------------------------------------\n// Options\n// ---------------------------------------------------------------------------\n\n/** Filter options controlling which pixels are sampled. */\nexport interface FilterOptions {\n    /** Skip white pixels. @default true */\n    ignoreWhite?: boolean;\n    /** RGB threshold above which a pixel is \"white\" (0–255). @default 250 */\n    whiteThreshold?: number;\n    /** Alpha threshold below which a pixel is transparent (0–255). @default 125 */\n    alphaThreshold?: number;\n    /** Minimum HSV saturation (0–1). @default 0 */\n    minSaturation?: number;\n}\n\n/** Color space used for quantization. */\nexport type ColorSpace = 'rgb' | 'oklch';\n\n/** Full extraction options. */\nexport interface ExtractionOptions extends FilterOptions {\n    /** Number of colors in the palette (2–20). @default 10 */\n    colorCount?: number;\n    /** Sampling quality (1 = highest). @default 10 */\n    quality?: number;\n    /** Color space for quantization. @default 'oklch' */\n    colorSpace?: ColorSpace;\n    /** AbortSignal to cancel extraction. */\n    signal?: AbortSignal;\n    /** Offload quantization to a Web Worker (browser only). @default false */\n    worker?: boolean;\n    /** Override the quantizer for this call only. Takes priority over configure(). */\n    quantizer?: Quantizer;\n    /** Override the pixel loader for this call only. Takes priority over configure(). */\n    loader?: PixelLoader<ImageSource>;\n}\n\n// ---------------------------------------------------------------------------\n// Color object\n// ---------------------------------------------------------------------------\n\n/** WCAG contrast information. */\nexport interface ContrastInfo {\n    /** Contrast ratio against white (1–21). */\n    white: number;\n    /** Contrast ratio against black (1–21). */\n    black: number;\n    /** Suggested foreground color for readability. */\n    foreground: Color;\n}\n\n/** Supported CSS color formats. */\nexport type CssColorFormat = 'rgb' | 'hsl' | 'oklch';\n\n/** A rich color extracted from an image. */\nexport interface Color {\n    /** RGB components. */\n    rgb(): RGB;\n    /** Hex string e.g. '#ff0000'. */\n    hex(): string;\n    /** HSL components. */\n    hsl(): HSL;\n    /** OKLCH components. */\n    oklch(): OKLCH;\n    /** CSS color string. @default 'rgb' */\n    css(format?: CssColorFormat): string;\n    /** RGB tuple [r, g, b]. */\n    array(): [number, number, number];\n    /** Hex string. Allows Color to be used directly in template literals and string contexts. */\n    toString(): string;\n    /** '#ffffff' or '#000000' — the readable text color for this background. */\n    readonly textColor: string;\n    /** True if the color is perceptually dark (relative luminance ≤ 0.179). */\n    readonly isDark: boolean;\n    /** True if the color is perceptually light. */\n    readonly isLight: boolean;\n    /** WCAG contrast ratios and suggested foreground. */\n    readonly contrast: ContrastInfo;\n    /** Relative population count from the quantizer. */\n    readonly population: number;\n    /** Proportion of total pixels represented by this color (0–1). */\n    readonly proportion: number;\n}\n\n// ---------------------------------------------------------------------------\n// Swatches\n// ---------------------------------------------------------------------------\n\nexport type SwatchRole =\n    | 'Vibrant'\n    | 'Muted'\n    | 'DarkVibrant'\n    | 'DarkMuted'\n    | 'LightVibrant'\n    | 'LightMuted';\n\n/** A semantic swatch with accessibility text colors. */\nexport interface Swatch {\n    color: Color;\n    role: SwatchRole;\n    titleTextColor: Color;\n    bodyTextColor: Color;\n}\n\n/** Map of swatch roles to their matched swatch (may be null if no good match). */\nexport type SwatchMap = Record<SwatchRole, Swatch | null>;\n\n// ---------------------------------------------------------------------------\n// Platform adapters\n// ---------------------------------------------------------------------------\n\n/** Contract for loading pixel data from a platform-specific source. */\nexport interface PixelLoader<TSource> {\n    /** Load and decode the source into raw pixel data. */\n    load(source: TSource, signal?: AbortSignal): Promise<PixelData>;\n}\n\n/** Pluggable quantization algorithm. */\nexport interface Quantizer {\n    /** One-time async initialization (e.g. loading WASM). */\n    init(): Promise<void>;\n    /** Quantize pixel array into up to maxColors clusters. */\n    quantize(\n        pixels: Array<[number, number, number]>,\n        maxColors: number,\n    ): Array<{ color: [number, number, number]; population: number }>;\n}\n\n// ---------------------------------------------------------------------------\n// Source types\n// ---------------------------------------------------------------------------\n\n/** Browser image source types. */\nexport type BrowserSource =\n    | HTMLImageElement\n    | HTMLCanvasElement\n    | HTMLVideoElement\n    | ImageData\n    | ImageBitmap\n    | OffscreenCanvas;\n\n/** Node.js image source types. */\nexport type NodeSource = string | Buffer;\n\n/** Union of all supported source types. */\nexport type ImageSource = BrowserSource | NodeSource;\n\n// ---------------------------------------------------------------------------\n// Progressive extraction\n// ---------------------------------------------------------------------------\n\n/** Yielded by the progressive extraction async generator. */\nexport interface ProgressiveResult {\n    palette: Color[];\n    /** Progress fraction (0–1). */\n    progress: number;\n    /** True when this is the final, full-quality pass. */\n    done: boolean;\n}\n"
  },
  {
    "path": "src/umd.ts",
    "content": "/**\n * UMD entry point — exposes ColorThief as a global with function-based API.\n *\n * Usage in a <script> tag:\n *   const color = ColorThief.getColorSync(imgElement);\n *   // or async:\n *   const color = await ColorThief.getColor(imgElement);\n *\n * This entry point imports directly from source modules to avoid pulling\n * in Node.js-only code (sharp, loaders/node) that would fail in IIFE builds.\n */\nexport {\n    getColor,\n    getPalette,\n    getSwatches,\n    getPaletteProgressive,\n    configure,\n} from './api.js';\n\nexport {\n    getColorSync,\n    getPaletteSync,\n    getSwatchesSync,\n} from './sync.js';\n\nexport { observe } from './observe.js';\n\nexport { createColor } from './color.js';\n"
  },
  {
    "path": "src/wasm/Cargo.toml",
    "content": "[package]\nname = \"color-thief-wasm\"\nversion = \"0.1.0\"\nedition = \"2021\"\npublish = false\n\n[lib]\ncrate-type = [\"cdylib\"]\n\n[dependencies]\nwasm-bindgen = \"0.2\"\n\n[profile.release]\nopt-level = \"s\"\nlto = true\n"
  },
  {
    "path": "src/wasm/src/lib.rs",
    "content": "use wasm_bindgen::prelude::*;\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst SIGBITS: u32 = 5;\nconst RSHIFT: u32 = 8 - SIGBITS;\nconst HIST_SIZE: usize = 1 << (3 * SIGBITS); // 32768\nconst MAX_ITERATIONS: usize = 1000;\nconst FRACT_BY_POPULATION: f64 = 0.75;\n\n// ---------------------------------------------------------------------------\n// 3D Color Histogram\n// ---------------------------------------------------------------------------\n\nfn color_index(r: u32, g: u32, b: u32) -> usize {\n    ((r << (2 * SIGBITS)) + (g << SIGBITS) + b) as usize\n}\n\nfn build_histogram(pixels: &[u8]) -> (Vec<u32>, usize) {\n    let mut hist = vec![0u32; HIST_SIZE];\n    let num_pixels = pixels.len() / 3;\n\n    for i in 0..num_pixels {\n        let r = (pixels[i * 3] as u32) >> RSHIFT;\n        let g = (pixels[i * 3 + 1] as u32) >> RSHIFT;\n        let b = (pixels[i * 3 + 2] as u32) >> RSHIFT;\n        hist[color_index(r, g, b)] += 1;\n    }\n\n    (hist, num_pixels)\n}\n\n// ---------------------------------------------------------------------------\n// VBox — a box in 5-bit quantized RGB space\n// ---------------------------------------------------------------------------\n\n#[derive(Clone)]\nstruct VBox {\n    r_min: u32,\n    r_max: u32,\n    g_min: u32,\n    g_max: u32,\n    b_min: u32,\n    b_max: u32,\n    count: u32,\n    volume: u32,\n}\n\nimpl VBox {\n    fn from_pixels(pixels: &[u8]) -> Self {\n        let mut r_min = 255u32;\n        let mut r_max = 0u32;\n        let mut g_min = 255u32;\n        let mut g_max = 0u32;\n        let mut b_min = 255u32;\n        let mut b_max = 0u32;\n\n        let num_pixels = pixels.len() / 3;\n        for i in 0..num_pixels {\n            let r = (pixels[i * 3] as u32) >> RSHIFT;\n            let g = (pixels[i * 3 + 1] as u32) >> RSHIFT;\n            let b = (pixels[i * 3 + 2] as u32) >> RSHIFT;\n            r_min = r_min.min(r);\n            r_max = r_max.max(r);\n            g_min = g_min.min(g);\n            g_max = g_max.max(g);\n            b_min = b_min.min(b);\n            b_max = b_max.max(b);\n        }\n\n        VBox {\n            r_min, r_max, g_min, g_max, b_min, b_max,\n            count: 0,\n            volume: 0,\n        }\n    }\n\n    fn update_count(&mut self, hist: &[u32]) {\n        let mut count = 0u32;\n        for r in self.r_min..=self.r_max {\n            for g in self.g_min..=self.g_max {\n                for b in self.b_min..=self.b_max {\n                    count += hist[color_index(r, g, b)];\n                }\n            }\n        }\n        self.count = count;\n    }\n\n    fn update_volume(&mut self) {\n        self.volume = (self.r_max - self.r_min + 1)\n            * (self.g_max - self.g_min + 1)\n            * (self.b_max - self.b_min + 1);\n    }\n\n    fn avg_color(&self, hist: &[u32]) -> (u8, u8, u8, u32) {\n        let mut r_sum = 0u64;\n        let mut g_sum = 0u64;\n        let mut b_sum = 0u64;\n        let mut total = 0u64;\n\n        for r in self.r_min..=self.r_max {\n            for g in self.g_min..=self.g_max {\n                for b in self.b_min..=self.b_max {\n                    let h = hist[color_index(r, g, b)] as u64;\n                    if h > 0 {\n                        total += h;\n                        r_sum += h * ((r << RSHIFT) + (1 << (RSHIFT - 1))) as u64;\n                        g_sum += h * ((g << RSHIFT) + (1 << (RSHIFT - 1))) as u64;\n                        b_sum += h * ((b << RSHIFT) + (1 << (RSHIFT - 1))) as u64;\n                    }\n                }\n            }\n        }\n\n        if total == 0 {\n            let r = ((self.r_min + self.r_max + 1) << RSHIFT) / 2;\n            let g = ((self.g_min + self.g_max + 1) << RSHIFT) / 2;\n            let b = ((self.b_min + self.b_max + 1) << RSHIFT) / 2;\n            return (r.min(255) as u8, g.min(255) as u8, b.min(255) as u8, 0);\n        }\n\n        (\n            (r_sum / total).min(255) as u8,\n            (g_sum / total).min(255) as u8,\n            (b_sum / total).min(255) as u8,\n            total as u32,\n        )\n    }\n\n    fn widest_dimension(&self) -> u8 {\n        let r_range = self.r_max - self.r_min;\n        let g_range = self.g_max - self.g_min;\n        let b_range = self.b_max - self.b_min;\n        if r_range >= g_range && r_range >= b_range { 0 }\n        else if g_range >= r_range && g_range >= b_range { 1 }\n        else { 2 }\n    }\n}\n\n// ---------------------------------------------------------------------------\n// Median cut\n// ---------------------------------------------------------------------------\n\nfn median_cut(hist: &[u32], vbox: &VBox) -> Option<(VBox, VBox)> {\n    if vbox.count <= 1 {\n        return None;\n    }\n\n    let dim = vbox.widest_dimension();\n\n    // Build partial sums along the widest dimension\n    let (range_min, range_max) = match dim {\n        0 => (vbox.r_min, vbox.r_max),\n        1 => (vbox.g_min, vbox.g_max),\n        _ => (vbox.b_min, vbox.b_max),\n    };\n\n    if range_min == range_max {\n        return None;\n    }\n\n    let mut partial_sum = vec![0i64; (range_max + 1) as usize];\n    let mut total = 0i64;\n\n    for i in range_min..=range_max {\n        let mut sum = 0i64;\n        match dim {\n            0 => {\n                for g in vbox.g_min..=vbox.g_max {\n                    for b in vbox.b_min..=vbox.b_max {\n                        sum += hist[color_index(i, g, b)] as i64;\n                    }\n                }\n            }\n            1 => {\n                for r in vbox.r_min..=vbox.r_max {\n                    for b in vbox.b_min..=vbox.b_max {\n                        sum += hist[color_index(r, i, b)] as i64;\n                    }\n                }\n            }\n            _ => {\n                for r in vbox.r_min..=vbox.r_max {\n                    for g in vbox.g_min..=vbox.g_max {\n                        sum += hist[color_index(r, g, i)] as i64;\n                    }\n                }\n            }\n        }\n        total += sum;\n        partial_sum[i as usize] = total;\n    }\n\n    // Find the split point\n    for i in range_min..=range_max {\n        if partial_sum[i as usize] > total / 2 {\n            let left = i - range_min;\n            let right = range_max - i;\n            let cut = if left <= right {\n                (i + right / 2).min(range_max - 1)\n            } else {\n                (i - 1 - left / 2).max(range_min)\n            };\n\n            let mut vbox1 = vbox.clone();\n            let mut vbox2 = vbox.clone();\n\n            match dim {\n                0 => { vbox1.r_max = cut; vbox2.r_min = cut + 1; }\n                1 => { vbox1.g_max = cut; vbox2.g_min = cut + 1; }\n                _ => { vbox1.b_max = cut; vbox2.b_min = cut + 1; }\n            }\n\n            vbox1.update_count(hist);\n            vbox1.update_volume();\n            vbox2.update_count(hist);\n            vbox2.update_volume();\n\n            return Some((vbox1, vbox2));\n        }\n    }\n\n    None\n}\n\n// ---------------------------------------------------------------------------\n// Priority queue (sort by comparator, split largest)\n// ---------------------------------------------------------------------------\n\nfn iterate(\n    queue: &mut Vec<VBox>,\n    hist: &[u32],\n    target: usize,\n    compare_by_product: bool,\n) {\n    let mut n_iters = 0;\n\n    loop {\n        if compare_by_product {\n            queue.sort_by(|a, b| {\n                let pa = (a.count as u64) * (a.volume as u64);\n                let pb = (b.count as u64) * (b.volume as u64);\n                pa.cmp(&pb)\n            });\n        } else {\n            queue.sort_by(|a, b| a.count.cmp(&b.count));\n        }\n\n        if queue.is_empty() {\n            return;\n        }\n\n        let vbox = queue.pop().unwrap();\n\n        if vbox.count == 0 {\n            queue.push(vbox);\n            return;\n        }\n\n        if let Some((vbox1, vbox2)) = median_cut(hist, &vbox) {\n            queue.push(vbox1);\n            if vbox2.count > 0 {\n                queue.push(vbox2);\n            }\n        } else {\n            queue.push(vbox);\n            return;\n        }\n\n        if queue.len() >= target {\n            return;\n        }\n\n        n_iters += 1;\n        if n_iters >= MAX_ITERATIONS {\n            return;\n        }\n    }\n}\n\n// ---------------------------------------------------------------------------\n// WASM entry point\n// ---------------------------------------------------------------------------\n\n/// Quantize pixel data (flat [r,g,b,r,g,b,...]) into a palette.\n/// Returns flat [r, g, b, population(u8,u8,u8,u8 LE), ...] — 7 bytes per color.\n/// We encode population as 4 little-endian bytes for simplicity.\n#[wasm_bindgen]\npub fn quantize(pixels: &[u8], max_colors: usize) -> Vec<u8> {\n    if pixels.is_empty() || max_colors == 0 {\n        return Vec::new();\n    }\n\n    let (hist, _num_pixels) = build_histogram(pixels);\n    let mut initial_box = VBox::from_pixels(pixels);\n    initial_box.update_count(&hist);\n    initial_box.update_volume();\n\n    let mut queue = vec![initial_box];\n\n    // Phase 1: split by population until 75% of target\n    let target1 = ((max_colors as f64) * FRACT_BY_POPULATION).ceil() as usize;\n    iterate(&mut queue, &hist, target1, false);\n\n    // Phase 2: split by population * volume for the rest\n    iterate(&mut queue, &hist, max_colors, true);\n\n    // Build output: 7 bytes per color (r, g, b, pop_le[4])\n    let mut result = Vec::with_capacity(queue.len() * 7);\n    for vbox in &queue {\n        let (r, g, b, pop) = vbox.avg_color(&hist);\n        result.push(r);\n        result.push(g);\n        result.push(b);\n        result.extend_from_slice(&pop.to_le_bytes());\n    }\n\n    result\n}\n\n// ---------------------------------------------------------------------------\n// Tests\n// ---------------------------------------------------------------------------\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_single_color() {\n        // 100 red pixels\n        let pixels: Vec<u8> = (0..100).flat_map(|_| vec![255, 0, 0]).collect();\n        let result = quantize(&pixels, 2);\n        assert!(!result.is_empty());\n        // First color should be near red\n        assert!(result[0] > 200); // r\n        assert!(result[1] < 50);  // g\n        assert!(result[2] < 50);  // b\n    }\n\n    #[test]\n    fn test_two_colors() {\n        let mut pixels = Vec::new();\n        for _ in 0..50 { pixels.extend_from_slice(&[255, 0, 0]); }\n        for _ in 0..50 { pixels.extend_from_slice(&[0, 0, 255]); }\n        let result = quantize(&pixels, 2);\n        // Should get 2 colors (14 bytes)\n        assert_eq!(result.len(), 14);\n    }\n\n    #[test]\n    fn test_empty_input() {\n        let result = quantize(&[], 5);\n        assert!(result.is_empty());\n    }\n}\n"
  },
  {
    "path": "src/worker/manager.ts",
    "content": "import type { Color } from '../types.js';\nimport { createColor } from '../color.js';\nimport { WORKER_SOURCE } from './worker-script.js';\n\nlet worker: Worker | null = null;\nlet blobUrl: string | null = null;\nlet nextId = 0;\nconst pending = new Map<\n    number,\n    { resolve: (value: Color[]) => void; reject: (reason: unknown) => void }\n>();\n\n/** Check whether the current environment supports Web Workers. */\nexport function isWorkerSupported(): boolean {\n    return typeof Worker !== 'undefined';\n}\n\nfunction getOrCreateWorker(): Worker {\n    if (worker) return worker;\n    if (!isWorkerSupported()) {\n        throw new Error('Web Workers are not supported in this environment.');\n    }\n    blobUrl = URL.createObjectURL(\n        new Blob([WORKER_SOURCE], { type: 'application/javascript' }),\n    );\n    worker = new Worker(blobUrl);\n    worker.onmessage = (e: MessageEvent) => {\n        const { id, palette, error } = e.data;\n        const entry = pending.get(id);\n        if (!entry) return;\n        pending.delete(id);\n        if (error) {\n            entry.reject(new Error(error));\n        } else {\n            const raw = palette as Array<{ color: [number, number, number]; population: number }>;\n            const totalPopulation = raw.reduce((sum: number, q: { population: number }) => sum + q.population, 0);\n            const colors = raw.map(({ color: [r, g, b], population }) =>\n                createColor(r, g, b, population, totalPopulation > 0 ? population / totalPopulation : 0));\n            entry.resolve(colors);\n        }\n    };\n    worker.onerror = (e) => {\n        // Reject all pending\n        for (const [, entry] of pending) {\n            entry.reject(new Error(e.message));\n        }\n        pending.clear();\n    };\n    return worker;\n}\n\n/**\n * Run quantization in a Web Worker.\n * @param pixels - Sampled pixel array (RGB triplets).\n * @param maxColors - Maximum palette size.\n * @param signal - Optional AbortSignal.\n */\nexport function extractInWorker(\n    pixels: Array<[number, number, number]>,\n    maxColors: number,\n    signal?: AbortSignal,\n): Promise<Color[]> {\n    return new Promise<Color[]>((resolve, reject) => {\n        if (signal?.aborted) {\n            reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));\n            return;\n        }\n\n        const id = nextId++;\n        pending.set(id, { resolve, reject });\n\n        const onAbort = () => {\n            pending.delete(id);\n            reject(signal!.reason ?? new DOMException('Aborted', 'AbortError'));\n        };\n\n        signal?.addEventListener('abort', onAbort, { once: true });\n\n        try {\n            const w = getOrCreateWorker();\n            w.postMessage({ id, pixels, maxColors });\n        } catch (err) {\n            pending.delete(id);\n            signal?.removeEventListener('abort', onAbort);\n            reject(err);\n        }\n    });\n}\n\n/** Terminate the worker and release the Blob URL. */\nexport function terminateWorker(): void {\n    if (worker) {\n        worker.terminate();\n        worker = null;\n    }\n    if (blobUrl) {\n        URL.revokeObjectURL(blobUrl);\n        blobUrl = null;\n    }\n    // Reject any outstanding requests\n    for (const [, entry] of pending) {\n        entry.reject(new Error('Worker terminated'));\n    }\n    pending.clear();\n}\n"
  },
  {
    "path": "src/worker/worker-script.ts",
    "content": "/**\n * Self-contained worker script that receives pixel data, runs quantization,\n * and returns the serialized palette.\n *\n * Message protocol:\n *   Request:  { id: number, pixels: number[][], maxColors: number }\n *   Response: { id: number, palette: Array<{ color: [r,g,b], population: number }> }\n *   Error:    { id: number, error: string }\n */\n\n// This entire string is turned into a Blob URL by the worker manager.\n// It must be fully self-contained — no imports, no external dependencies.\nexport const WORKER_SOURCE = /* js */ `\n'use strict';\n\n// -------------------------------------------------------------------------\n// Inlined MMCQ (Modified Median Cut Quantization)\n// -------------------------------------------------------------------------\n\nvar SIGBITS = 5;\nvar RSHIFT = 3;\nvar MAX_ITER = 1000;\nvar FRACT_POP = 0.75;\nvar HISTO_SIZE = 32768;\n\nfunction colorIndex(r, g, b) {\n    return (r << 10) + (g << 5) + b;\n}\n\nfunction getHisto(pixels) {\n    var h = new Uint32Array(HISTO_SIZE);\n    for (var i = 0; i < pixels.length; i++) {\n        var p = pixels[i];\n        h[colorIndex(p[0] >> RSHIFT, p[1] >> RSHIFT, p[2] >> RSHIFT)]++;\n    }\n    return h;\n}\n\nfunction VBox(r1, r2, g1, g2, b1, b2, histo) {\n    this.r1 = r1; this.r2 = r2;\n    this.g1 = g1; this.g2 = g2;\n    this.b1 = b1; this.b2 = b2;\n    this.histo = histo;\n    this._count = -1;\n    this._volume = -1;\n    this._avg = null;\n}\n\nVBox.prototype.volume = function(force) {\n    if (this._volume < 0 || force) {\n        this._volume = (this.r2 - this.r1 + 1) * (this.g2 - this.g1 + 1) * (this.b2 - this.b1 + 1);\n    }\n    return this._volume;\n};\n\nVBox.prototype.count = function(force) {\n    if (this._count < 0 || force) {\n        var n = 0;\n        for (var i = this.r1; i <= this.r2; i++)\n            for (var j = this.g1; j <= this.g2; j++)\n                for (var k = this.b1; k <= this.b2; k++)\n                    n += this.histo[colorIndex(i, j, k)] || 0;\n        this._count = n;\n    }\n    return this._count;\n};\n\nVBox.prototype.copy = function() {\n    return new VBox(this.r1, this.r2, this.g1, this.g2, this.b1, this.b2, this.histo);\n};\n\nVBox.prototype.avg = function(force) {\n    if (!this._avg || force) {\n        var mult = 1 << RSHIFT;\n        if (this.r1 === this.r2 && this.g1 === this.g2 && this.b1 === this.b2) {\n            this._avg = [this.r1 << RSHIFT, this.g1 << RSHIFT, this.b1 << RSHIFT];\n        } else {\n            var ntot = 0, rsum = 0, gsum = 0, bsum = 0;\n            for (var i = this.r1; i <= this.r2; i++)\n                for (var j = this.g1; j <= this.g2; j++)\n                    for (var k = this.b1; k <= this.b2; k++) {\n                        var hval = this.histo[colorIndex(i, j, k)] || 0;\n                        ntot += hval;\n                        rsum += hval * (i + 0.5) * mult;\n                        gsum += hval * (j + 0.5) * mult;\n                        bsum += hval * (k + 0.5) * mult;\n                    }\n            this._avg = ntot\n                ? [~~(rsum / ntot), ~~(gsum / ntot), ~~(bsum / ntot)]\n                : [~~(mult * (this.r1 + this.r2 + 1) / 2), ~~(mult * (this.g1 + this.g2 + 1) / 2), ~~(mult * (this.b1 + this.b2 + 1) / 2)];\n        }\n    }\n    return this._avg;\n};\n\nfunction PQueue(comparator) {\n    this.contents = [];\n    this.sorted = false;\n    this.comparator = comparator;\n}\n\nPQueue.prototype.push = function(item) { this.contents.push(item); this.sorted = false; };\nPQueue.prototype.pop = function() {\n    if (!this.sorted) { this.contents.sort(this.comparator); this.sorted = true; }\n    return this.contents.pop();\n};\nPQueue.prototype.size = function() { return this.contents.length; };\n\nfunction vboxFromPixels(pixels, histo) {\n    var rmin = 1e6, rmax = 0, gmin = 1e6, gmax = 0, bmin = 1e6, bmax = 0;\n    for (var i = 0; i < pixels.length; i++) {\n        var p = pixels[i];\n        var rv = p[0] >> RSHIFT, gv = p[1] >> RSHIFT, bv = p[2] >> RSHIFT;\n        if (rv < rmin) rmin = rv; if (rv > rmax) rmax = rv;\n        if (gv < gmin) gmin = gv; if (gv > gmax) gmax = gv;\n        if (bv < bmin) bmin = bv; if (bv > bmax) bmax = bv;\n    }\n    return new VBox(rmin, rmax, gmin, gmax, bmin, bmax, histo);\n}\n\nfunction medianCutApply(histo, vbox) {\n    if (!vbox.count()) return undefined;\n    if (vbox.count() === 1) return [vbox.copy(), null];\n\n    var rw = vbox.r2 - vbox.r1 + 1;\n    var gw = vbox.g2 - vbox.g1 + 1;\n    var bw = vbox.b2 - vbox.b1 + 1;\n    var maxw = Math.max(rw, gw, bw);\n    var total = 0;\n    var partialsum = [];\n    var lookaheadsum = [];\n    var i, j, k, sum;\n\n    if (maxw === rw) {\n        for (i = vbox.r1; i <= vbox.r2; i++) {\n            sum = 0;\n            for (j = vbox.g1; j <= vbox.g2; j++)\n                for (k = vbox.b1; k <= vbox.b2; k++)\n                    sum += histo[colorIndex(i, j, k)] || 0;\n            total += sum; partialsum[i] = total;\n        }\n    } else if (maxw === gw) {\n        for (i = vbox.g1; i <= vbox.g2; i++) {\n            sum = 0;\n            for (j = vbox.r1; j <= vbox.r2; j++)\n                for (k = vbox.b1; k <= vbox.b2; k++)\n                    sum += histo[colorIndex(j, i, k)] || 0;\n            total += sum; partialsum[i] = total;\n        }\n    } else {\n        for (i = vbox.b1; i <= vbox.b2; i++) {\n            sum = 0;\n            for (j = vbox.r1; j <= vbox.r2; j++)\n                for (k = vbox.g1; k <= vbox.g2; k++)\n                    sum += histo[colorIndex(j, k, i)] || 0;\n            total += sum; partialsum[i] = total;\n        }\n    }\n\n    partialsum.forEach(function(d, idx) { lookaheadsum[idx] = total - d; });\n\n    function doCut(color) {\n        var dim1 = color + '1', dim2 = color + '2';\n        for (var i = vbox[dim1]; i <= vbox[dim2]; i++) {\n            if (partialsum[i] > total / 2) {\n                var vbox1 = vbox.copy(), vbox2 = vbox.copy();\n                var left = i - vbox[dim1], right = vbox[dim2] - i;\n                var d2 = left <= right\n                    ? Math.min(vbox[dim2] - 1, ~~(i + right / 2))\n                    : Math.max(vbox[dim1], ~~(i - 1 - left / 2));\n                while (!partialsum[d2]) d2++;\n                var count2 = lookaheadsum[d2];\n                while (!count2 && partialsum[d2 - 1]) count2 = lookaheadsum[--d2];\n                vbox1[dim2] = d2;\n                vbox2[dim1] = d2 + 1;\n                return [vbox1, vbox2];\n            }\n        }\n    }\n\n    if (maxw === rw) return doCut('r');\n    if (maxw === gw) return doCut('g');\n    return doCut('b');\n}\n\nfunction iterate(pq, target, histo) {\n    var ncolors = pq.size(), niters = 0;\n    while (niters < MAX_ITER) {\n        if (ncolors >= target) return;\n        niters++;\n        var vbox = pq.pop();\n        if (!vbox.count()) { pq.push(vbox); continue; }\n        var result = medianCutApply(histo, vbox);\n        if (!result || !result[0]) return;\n        pq.push(result[0]);\n        if (result[1]) { pq.push(result[1]); ncolors++; }\n    }\n}\n\nfunction quantize(pixels, maxColors) {\n    if (!pixels.length || maxColors < 2 || maxColors > 256) return [];\n\n    var histo = getHisto(pixels);\n    var vbox = vboxFromPixels(pixels, histo);\n    var pq = new PQueue(function(a, b) { return a.count() - b.count(); });\n    pq.push(vbox);\n    iterate(pq, FRACT_POP * maxColors, histo);\n\n    var pq2 = new PQueue(function(a, b) { return a.count() * a.volume() - b.count() * b.volume(); });\n    while (pq.size()) pq2.push(pq.pop());\n    iterate(pq2, maxColors, histo);\n\n    var results = [];\n    while (pq2.size()) {\n        var box = pq2.pop();\n        results.push({ color: box.avg(), population: box.count() });\n    }\n    return results;\n}\n\n// -------------------------------------------------------------------------\n// Worker message handler\n// -------------------------------------------------------------------------\n\nself.onmessage = function (e) {\n    var data = e.data;\n    var id = data.id;\n    try {\n        var palette = quantize(data.pixels, data.maxColors);\n        self.postMessage({ id: id, palette: palette });\n    } catch (err) {\n        self.postMessage({ id: id, error: err.message || 'Unknown worker error' });\n    }\n};\n`;\n"
  },
  {
    "path": "test/cli-test.js",
    "content": "import { resolve } from 'path';\nimport { execFile } from 'child_process';\nimport { readFileSync } from 'fs';\nimport { promisify } from 'util';\nimport chai from 'chai';\n\nconst expect = chai.expect;\nconst execFileAsync = promisify(execFile);\n\nconst cli = resolve(process.cwd(), 'dist/cli.js');\nconst imgDir = resolve(process.cwd(), 'cypress/test-pages/img');\nconst img = (name) => resolve(imgDir, name);\n\nfunction run(...args) {\n    return execFileAsync('node', [cli, ...args], { timeout: 15000 });\n}\n\nfunction runWithStdin(filePath, ...args) {\n    return new Promise((resolve, reject) => {\n        const child = execFile('node', [cli, ...args], { timeout: 15000 }, (err, stdout, stderr) => {\n            if (err) reject(err);\n            else resolve({ stdout, stderr });\n        });\n        const data = readFileSync(filePath);\n        child.stdin.write(data);\n        child.stdin.end();\n    });\n}\n\n// ===========================================================================\n// CLI\n// ===========================================================================\n\ndescribe('CLI', function () {\n    this.timeout(15000);\n\n    // -----------------------------------------------------------------------\n    // --help / --version\n    // -----------------------------------------------------------------------\n\n    it('--help shows usage', async function () {\n        const { stdout } = await run('--help');\n        expect(stdout).to.include('Usage:');\n        expect(stdout).to.include('colorthief');\n    });\n\n    it('-h shows usage', async function () {\n        const { stdout } = await run('-h');\n        expect(stdout).to.include('Usage:');\n    });\n\n    it('--version shows version', async function () {\n        const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8'));\n        const { stdout } = await run('--version');\n        expect(stdout.trim()).to.equal(pkg.version);\n    });\n\n    it('-v shows version', async function () {\n        const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8'));\n        const { stdout } = await run('-v');\n        expect(stdout.trim()).to.equal(pkg.version);\n    });\n\n    // -----------------------------------------------------------------------\n    // color (default command)\n    // -----------------------------------------------------------------------\n\n    it('outputs hex for dominant color', async function () {\n        const { stdout } = await run(img('red.png'));\n        expect(stdout).to.match(/#[0-9a-f]{6}/i);\n    });\n\n    it('dominant color of red.png is close to red', async function () {\n        const { stdout } = await run(img('red.png'), '--json');\n        const data = JSON.parse(stdout);\n        expect(data.rgb.r).to.be.greaterThan(200);\n        expect(data.rgb.g).to.be.lessThan(50);\n        expect(data.rgb.b).to.be.lessThan(50);\n    });\n\n    // -----------------------------------------------------------------------\n    // --json\n    // -----------------------------------------------------------------------\n\n    it('--json returns valid JSON with expected fields', async function () {\n        const { stdout } = await run(img('red.png'), '--json');\n        const data = JSON.parse(stdout);\n        expect(data).to.have.property('hex');\n        expect(data).to.have.property('rgb');\n        expect(data).to.have.property('hsl');\n        expect(data).to.have.property('oklch');\n        expect(data).to.have.property('isDark');\n        expect(data).to.have.property('population');\n        expect(data).to.have.property('proportion');\n    });\n\n    // -----------------------------------------------------------------------\n    // palette\n    // -----------------------------------------------------------------------\n\n    it('palette subcommand returns array in JSON mode', async function () {\n        const { stdout } = await run('palette', img('rainbow-horizontal.png'), '--json');\n        const data = JSON.parse(stdout);\n        expect(data).to.be.an('array');\n        expect(data.length).to.be.greaterThan(1);\n        expect(data[0]).to.have.property('hex');\n    });\n\n    it('palette --count limits colors', async function () {\n        const { stdout } = await run('palette', img('rainbow-horizontal.png'), '--json', '--count', '3');\n        const data = JSON.parse(stdout);\n        expect(data).to.be.an('array');\n        expect(data.length).to.be.at.most(3);\n    });\n\n    it('palette default output shows hex values', async function () {\n        const { stdout } = await run('palette', img('rainbow-horizontal.png'));\n        const hexes = stdout.match(/#[0-9a-f]{6}/gi);\n        expect(hexes).to.not.be.null;\n        expect(hexes.length).to.be.greaterThan(1);\n    });\n\n    // -----------------------------------------------------------------------\n    // swatches\n    // -----------------------------------------------------------------------\n\n    it('swatches returns all roles in JSON', async function () {\n        const { stdout } = await run('swatches', img('rainbow-horizontal.png'), '--json');\n        const data = JSON.parse(stdout);\n        const expectedRoles = ['Vibrant', 'Muted', 'DarkVibrant', 'DarkMuted', 'LightVibrant', 'LightMuted'];\n        for (const role of expectedRoles) {\n            expect(data).to.have.property(role);\n        }\n    });\n\n    it('swatches default output shows role names', async function () {\n        const { stdout } = await run('swatches', img('rainbow-horizontal.png'));\n        expect(stdout).to.include('Vibrant');\n        expect(stdout).to.include('Muted');\n    });\n\n    // -----------------------------------------------------------------------\n    // --css\n    // -----------------------------------------------------------------------\n\n    it('--css for color outputs custom properties', async function () {\n        const { stdout } = await run(img('red.png'), '--css');\n        expect(stdout).to.include(':root');\n        expect(stdout).to.include('--color-dominant');\n    });\n\n    it('--css for palette outputs numbered properties', async function () {\n        const { stdout } = await run('palette', img('rainbow-horizontal.png'), '--css');\n        expect(stdout).to.include(':root');\n        expect(stdout).to.include('--color-1');\n    });\n\n    it('--css for swatches outputs swatch properties', async function () {\n        const { stdout } = await run('swatches', img('rainbow-horizontal.png'), '--css');\n        expect(stdout).to.include(':root');\n        expect(stdout).to.include('--swatch-vibrant');\n        expect(stdout).to.include('--swatch-dark-muted');\n    });\n\n    // -----------------------------------------------------------------------\n    // stdin\n    // -----------------------------------------------------------------------\n\n    it('reads from stdin with \"-\" argument', async function () {\n        const { stdout } = await runWithStdin(img('red.png'), '-', '--json');\n        const data = JSON.parse(stdout);\n        expect(data).to.have.property('hex');\n        expect(data.rgb.r).to.be.greaterThan(200);\n    });\n\n    // -----------------------------------------------------------------------\n    // multi-file\n    // -----------------------------------------------------------------------\n\n    it('multi-file JSON wraps in object keyed by filename', async function () {\n        const { stdout } = await run(img('red.png'), img('black.png'), '--json');\n        const data = JSON.parse(stdout);\n        expect(data).to.have.property(img('red.png'));\n        expect(data).to.have.property(img('black.png'));\n    });\n\n    it('multi-file default output prefixes with filename', async function () {\n        const { stdout } = await run(img('red.png'), img('black.png'));\n        expect(stdout).to.include('red.png:');\n        expect(stdout).to.include('black.png:');\n    });\n\n    // -----------------------------------------------------------------------\n    // --color-space\n    // -----------------------------------------------------------------------\n\n    it('--color-space rgb works', async function () {\n        const { stdout } = await run(img('red.png'), '--json', '--color-space', 'rgb');\n        const data = JSON.parse(stdout);\n        expect(data).to.have.property('hex');\n    });\n\n    // -----------------------------------------------------------------------\n    // error cases\n    // -----------------------------------------------------------------------\n\n    it('exits with error for missing file', async function () {\n        try {\n            await run('nonexistent.png');\n            expect.fail('should have thrown');\n        } catch (err) {\n            expect(err.code).to.not.equal(0);\n        }\n    });\n});\n"
  },
  {
    "path": "test/node-cjs-test.cjs",
    "content": "const { resolve } = require('path');\nconst { readFileSync } = require('fs');\nconst { getColor, getPalette, createColor } = require('../dist/index.cjs');\n\nconst chai = require('chai');\nconst chaiAsPromised = require('chai-as-promised');\n\nconst expect = chai.expect;\nchai.use(chaiAsPromised);\n\nconst imgDir = resolve(process.cwd(), 'cypress/test-pages/img');\nconst imgPath = (name) => resolve(imgDir, name);\n\nfunction isColorObject(c) {\n    return (\n        c !== null &&\n        typeof c.rgb === 'function' &&\n        typeof c.hex === 'function' &&\n        typeof c.array === 'function' &&\n        typeof c.isDark === 'boolean' &&\n        typeof c.population === 'number'\n    );\n}\n\ndescribe('CommonJS require()', function() {\n    it('getColor works via require()', async function() {\n        const color = await getColor(imgPath('rainbow-vertical.png'));\n        expect(isColorObject(color)).to.be.true;\n    });\n\n    it('getPalette works via require()', async function() {\n        const palette = await getPalette(imgPath('rainbow-vertical.png'), { colorCount: 5 });\n        expect(palette).to.have.lengthOf(5);\n        palette.forEach(c => expect(isColorObject(c)).to.be.true);\n    });\n\n    it('createColor works via require()', function() {\n        const c = createColor(255, 0, 0, 1);\n        expect(c.hex()).to.equal('#ff0000');\n        expect(c.isDark).to.be.false;\n    });\n});\n"
  },
  {
    "path": "test/node-test.js",
    "content": "import { resolve } from 'path';\nimport { readFileSync } from 'fs';\nimport { getColor, getPalette, getSwatches, getPaletteProgressive, createColor } from '../dist/index.js';\nimport { rgbToOklch, oklchToRgb } from '../dist/internals.js';\nimport chai from 'chai';\nimport chaiAsPromised from 'chai-as-promised';\n\nconst expect = chai.expect;\nchai.use(chaiAsPromised);\n\nconst imgDir = resolve(process.cwd(), 'cypress/test-pages/img');\nconst imgPath = (name) => resolve(imgDir, name);\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction isColorObject(c) {\n    return (\n        c !== null &&\n        typeof c.rgb === 'function' &&\n        typeof c.hex === 'function' &&\n        typeof c.hsl === 'function' &&\n        typeof c.oklch === 'function' &&\n        typeof c.css === 'function' &&\n        typeof c.array === 'function' &&\n        typeof c.isDark === 'boolean' &&\n        typeof c.isLight === 'boolean' &&\n        typeof c.population === 'number' &&\n        typeof c.proportion === 'number'\n    );\n}\n\nfunction isValidRGB(color) {\n    const { r, g, b } = color.rgb();\n    return [r, g, b].every(v => Number.isInteger(v) && v >= 0 && v <= 255);\n}\n\nfunction isCloseTo(color, expected, tolerance = 15) {\n    const arr = color.array();\n    return arr.every((v, i) => Math.abs(v - expected[i]) <= tolerance);\n}\n\n\n// ===========================================================================\n// getColor()\n// ===========================================================================\n\ndescribe('getColor()', function() {\n    it('returns a Color object from file path', async function() {\n        const color = await getColor(imgPath('rainbow-vertical.png'));\n        expect(isColorObject(color)).to.be.true;\n        expect(isValidRGB(color)).to.be.true;\n    });\n\n    it('returns a Color object from Buffer', async function() {\n        const buffer = readFileSync(imgPath('rainbow-vertical.png'));\n        const color = await getColor(buffer);\n        expect(isColorObject(color)).to.be.true;\n        expect(isValidRGB(color)).to.be.true;\n    });\n\n    it('returns near-black for black.png', async function() {\n        const color = await getColor(imgPath('black.png'));\n        expect(isColorObject(color)).to.be.true;\n        expect(isCloseTo(color, [0, 0, 0])).to.be.true;\n    });\n\n    it('returns near-red for red.png', async function() {\n        const color = await getColor(imgPath('red.png'));\n        expect(isColorObject(color)).to.be.true;\n        expect(isCloseTo(color, [255, 0, 0])).to.be.true;\n    });\n\n    it('returns valid Color for white.png', async function() {\n        const color = await getColor(imgPath('white.png'));\n        expect(isColorObject(color)).to.be.true;\n        expect(isCloseTo(color, [255, 255, 255])).to.be.true;\n    });\n\n    it('returns valid Color for transparent.png', async function() {\n        const color = await getColor(imgPath('transparent.png'));\n        expect(isColorObject(color)).to.be.true;\n    });\n\n    it('respects quality option (quality=1)', async function() {\n        const color = await getColor(imgPath('rainbow-vertical.png'), { quality: 1 });\n        expect(isColorObject(color)).to.be.true;\n    });\n\n    it('respects quality option (quality=100)', async function() {\n        const color = await getColor(imgPath('rainbow-vertical.png'), { quality: 100 });\n        expect(isColorObject(color)).to.be.true;\n    });\n\n    it('rejects for non-existent file path', async function() {\n        await expect(getColor('/non/existent/file.png')).to.be.rejected;\n    });\n});\n\n\n// ===========================================================================\n// getPalette()\n// ===========================================================================\n\ndescribe('getPalette()', function() {\n    it('returns 10 colors with default colorCount', async function() {\n        const palette = await getPalette(imgPath('rainbow-vertical.png'));\n        expect(palette).to.have.lengthOf(10);\n        palette.forEach(c => {\n            expect(isColorObject(c)).to.be.true;\n            expect(isValidRGB(c)).to.be.true;\n        });\n    });\n\n    it('returns 2 colors (boundary min)', async function() {\n        const palette = await getPalette(imgPath('rainbow-vertical.png'), { colorCount: 2 });\n        expect(palette).to.have.lengthOf(2);\n    });\n\n    it('returns 20 colors (boundary max)', async function() {\n        const palette = await getPalette(imgPath('rainbow-vertical.png'), { colorCount: 20 });\n        expect(palette).to.have.lengthOf(20);\n    });\n\n    it('clamps colorCount=0 to 2', async function() {\n        const palette = await getPalette(imgPath('rainbow-vertical.png'), { colorCount: 0 });\n        expect(palette).to.have.lengthOf(2);\n    });\n\n    it('clamps colorCount=-1 to 2', async function() {\n        const palette = await getPalette(imgPath('rainbow-vertical.png'), { colorCount: -1 });\n        expect(palette).to.have.lengthOf(2);\n    });\n\n    it('clamps colorCount=21 to 20', async function() {\n        const palette = await getPalette(imgPath('rainbow-vertical.png'), { colorCount: 21 });\n        expect(palette).to.have.lengthOf(20);\n    });\n\n    it('rejects when colorCount=1', async function() {\n        await expect(getPalette(imgPath('rainbow-vertical.png'), { colorCount: 1 })).to.be.rejected;\n    });\n\n    it('defaults non-integer colorCount (5.5) to 10', async function() {\n        const palette = await getPalette(imgPath('rainbow-vertical.png'), { colorCount: 5.5 });\n        expect(palette).to.have.lengthOf(10);\n    });\n\n    it('returns valid palette for white.png', async function() {\n        const palette = await getPalette(imgPath('white.png'));\n        expect(palette).to.be.an('array').that.is.not.empty;\n        palette.forEach(c => expect(isColorObject(c)).to.be.true);\n    });\n\n    it('returns valid palette for transparent.png', async function() {\n        const palette = await getPalette(imgPath('transparent.png'));\n        expect(palette).to.be.an('array').that.is.not.empty;\n        palette.forEach(c => expect(isColorObject(c)).to.be.true);\n    });\n\n    it('works with Buffer input', async function() {\n        const buffer = readFileSync(imgPath('rainbow-vertical.png'));\n        const palette = await getPalette(buffer, { colorCount: 5 });\n        expect(palette).to.have.lengthOf(5);\n        palette.forEach(c => expect(isColorObject(c)).to.be.true);\n    });\n\n    it('rejects for non-existent file', async function() {\n        await expect(getPalette('/non/existent/file.png')).to.be.rejected;\n    });\n\n    it('palette colors have proportion summing to ~1', async function() {\n        const palette = await getPalette(imgPath('rainbow-vertical.png'), { colorCount: 5 });\n        const totalProportion = palette.reduce((sum, c) => sum + c.proportion, 0);\n        expect(totalProportion).to.be.closeTo(1, 0.01);\n        palette.forEach(c => {\n            expect(c.proportion).to.be.a('number');\n            expect(c.proportion).to.be.greaterThan(0);\n            expect(c.proportion).to.be.at.most(1);\n        });\n    });\n});\n\n\n// ===========================================================================\n// Color object\n// ===========================================================================\n\ndescribe('Color object', function() {\n    it('rgb() returns {r, g, b}', function() {\n        const c = createColor(255, 128, 0, 1);\n        const { r, g, b } = c.rgb();\n        expect(r).to.equal(255);\n        expect(g).to.equal(128);\n        expect(b).to.equal(0);\n    });\n\n    it('hex() returns lowercase hex string', function() {\n        const c = createColor(255, 128, 0, 1);\n        expect(c.hex()).to.equal('#ff8000');\n    });\n\n    it('hex() pads zeros correctly', function() {\n        const c = createColor(0, 0, 0, 1);\n        expect(c.hex()).to.equal('#000000');\n    });\n\n    it('array() returns [r, g, b]', function() {\n        const c = createColor(10, 20, 30, 1);\n        expect(c.array()).to.deep.equal([10, 20, 30]);\n    });\n\n    it('hsl() returns {h, s, l}', function() {\n        const c = createColor(255, 0, 0, 1);\n        const hsl = c.hsl();\n        expect(hsl.h).to.equal(0);\n        expect(hsl.s).to.equal(100);\n        expect(hsl.l).to.equal(50);\n    });\n\n    it('oklch() returns {l, c, h}', function() {\n        const c = createColor(255, 0, 0, 1);\n        const oklch = c.oklch();\n        expect(oklch.l).to.be.a('number');\n        expect(oklch.c).to.be.a('number');\n        expect(oklch.h).to.be.a('number');\n        expect(oklch.l).to.be.greaterThan(0);\n        expect(oklch.c).to.be.greaterThan(0);\n    });\n\n    it('isDark is true for black', function() {\n        expect(createColor(0, 0, 0, 1).isDark).to.be.true;\n    });\n\n    it('isLight is true for white', function() {\n        expect(createColor(255, 255, 255, 1).isLight).to.be.true;\n    });\n\n    it('isDark is true for dark red', function() {\n        expect(createColor(128, 0, 0, 1).isDark).to.be.true;\n    });\n\n    it('isLight is true for light yellow', function() {\n        expect(createColor(255, 255, 128, 1).isLight).to.be.true;\n    });\n\n    it('contrast has white and black ratios', function() {\n        const c = createColor(128, 128, 128, 1);\n        expect(c.contrast.white).to.be.a('number');\n        expect(c.contrast.black).to.be.a('number');\n        expect(c.contrast.white).to.be.greaterThan(1);\n        expect(c.contrast.black).to.be.greaterThan(1);\n    });\n\n    it('contrast foreground is white for dark colors', function() {\n        const c = createColor(0, 0, 0, 1);\n        expect(c.contrast.foreground.array()).to.deep.equal([255, 255, 255]);\n    });\n\n    it('contrast foreground is black for light colors', function() {\n        const c = createColor(255, 255, 255, 1);\n        expect(c.contrast.foreground.array()).to.deep.equal([0, 0, 0]);\n    });\n\n    it('population is stored', function() {\n        expect(createColor(0, 0, 0, 42).population).to.equal(42);\n    });\n\n    it('proportion defaults to 0', function() {\n        expect(createColor(0, 0, 0, 42).proportion).to.equal(0);\n    });\n\n    it('css() returns rgb string by default', function() {\n        const c = createColor(255, 128, 0, 1);\n        expect(c.css()).to.equal('rgb(255, 128, 0)');\n    });\n\n    it(\"css('rgb') returns rgb string\", function() {\n        const c = createColor(255, 128, 0, 1);\n        expect(c.css('rgb')).to.equal('rgb(255, 128, 0)');\n    });\n\n    it(\"css('hsl') returns hsl string\", function() {\n        const c = createColor(255, 0, 0, 1);\n        expect(c.css('hsl')).to.equal('hsl(0, 100%, 50%)');\n    });\n\n    it(\"css('oklch') returns oklch string\", function() {\n        const c = createColor(255, 0, 0, 1);\n        const result = c.css('oklch');\n        expect(result).to.match(/^oklch\\(\\d+\\.\\d+ \\d+\\.\\d+ \\d+\\.\\d+\\)$/);\n    });\n\n    it('toString() returns hex string', function() {\n        const c = createColor(255, 128, 0, 1);\n        expect(c.toString()).to.equal('#ff8000');\n        expect(`${c}`).to.equal('#ff8000');\n        expect('' + c).to.equal('#ff8000');\n    });\n\n    it('textColor is #ffffff for dark colors', function() {\n        expect(createColor(0, 0, 0, 1).textColor).to.equal('#ffffff');\n        expect(createColor(128, 0, 0, 1).textColor).to.equal('#ffffff');\n    });\n\n    it('textColor is #000000 for light colors', function() {\n        expect(createColor(255, 255, 255, 1).textColor).to.equal('#000000');\n        expect(createColor(255, 255, 128, 1).textColor).to.equal('#000000');\n    });\n});\n\n\n// ===========================================================================\n// Color space conversions\n// ===========================================================================\n\ndescribe('Color space round-trip', function() {\n    const TEST_COLORS = [\n        [255, 0, 0],\n        [0, 255, 0],\n        [0, 0, 255],\n        [128, 128, 128],\n        [255, 255, 0],\n        [0, 255, 255],\n        [255, 0, 255],\n        [0, 0, 0],\n        [255, 255, 255],\n    ];\n\n    TEST_COLORS.forEach(([r, g, b]) => {\n        it(`RGB(${r},${g},${b}) → OKLCH → RGB within ±1`, function() {\n            const oklch = rgbToOklch(r, g, b);\n            const [r2, g2, b2] = oklchToRgb(oklch.l, oklch.c, oklch.h);\n            expect(Math.abs(r - r2)).to.be.at.most(1);\n            expect(Math.abs(g - g2)).to.be.at.most(1);\n            expect(Math.abs(b - b2)).to.be.at.most(1);\n        });\n    });\n});\n\n\n// ===========================================================================\n// getSwatches()\n// ===========================================================================\n\ndescribe('getSwatches()', function() {\n    it('returns a SwatchMap with all 6 roles', async function() {\n        const swatches = await getSwatches(imgPath('rainbow-vertical.png'));\n        const roles = ['Vibrant', 'Muted', 'DarkVibrant', 'DarkMuted', 'LightVibrant', 'LightMuted'];\n        roles.forEach(role => {\n            expect(swatches).to.have.property(role);\n        });\n    });\n\n    it('each swatch has expected structure', async function() {\n        const swatches = await getSwatches(imgPath('rainbow-vertical.png'));\n        for (const [, swatch] of Object.entries(swatches)) {\n            if (swatch !== null) {\n                expect(isColorObject(swatch.color)).to.be.true;\n                expect(swatch.role).to.be.a('string');\n                expect(isColorObject(swatch.titleTextColor)).to.be.true;\n                expect(isColorObject(swatch.bodyTextColor)).to.be.true;\n            }\n        }\n    });\n});\n\n\n// ===========================================================================\n// OKLCH color space option\n// ===========================================================================\n\ndescribe('OKLCH color space', function() {\n    it('getPalette with colorSpace: oklch returns valid colors', async function() {\n        const palette = await getPalette(imgPath('rainbow-vertical.png'), {\n            colorCount: 5,\n            colorSpace: 'oklch',\n        });\n        expect(palette).to.have.lengthOf(5);\n        palette.forEach(c => {\n            expect(isColorObject(c)).to.be.true;\n            expect(isValidRGB(c)).to.be.true;\n        });\n    });\n});\n\n\n// ===========================================================================\n// AbortController\n// ===========================================================================\n\ndescribe('AbortController', function() {\n    it('rejects when signal is already aborted', async function() {\n        const controller = new AbortController();\n        controller.abort();\n        await expect(\n            getColor(imgPath('rainbow-vertical.png'), { signal: controller.signal })\n        ).to.be.rejected;\n    });\n});\n\n\n// ===========================================================================\n// Progressive extraction\n// ===========================================================================\n\ndescribe('getPaletteProgressive()', function() {\n    it('yields 3 results with increasing progress', async function() {\n        const results = [];\n        for await (const result of getPaletteProgressive(imgPath('rainbow-vertical.png'), { colorCount: 5 })) {\n            results.push(result);\n        }\n        expect(results).to.have.lengthOf(3);\n        expect(results[0].progress).to.be.closeTo(0.06, 0.01);\n        expect(results[1].progress).to.be.closeTo(0.25, 0.01);\n        expect(results[2].progress).to.equal(1.0);\n        expect(results[2].done).to.be.true;\n        expect(results[0].done).to.be.false;\n    });\n\n    it('final pass returns Color objects', async function() {\n        const results = [];\n        for await (const result of getPaletteProgressive(imgPath('rainbow-vertical.png'), { colorCount: 5 })) {\n            results.push(result);\n        }\n        const final = results[results.length - 1];\n        expect(final.palette).to.have.lengthOf(5);\n        final.palette.forEach(c => expect(isColorObject(c)).to.be.true);\n    });\n});\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n    \"compilerOptions\": {\n        \"target\": \"ES2020\",\n        \"module\": \"ESNext\",\n        \"moduleResolution\": \"bundler\",\n        \"strict\": true,\n        \"esModuleInterop\": true,\n        \"skipLibCheck\": true,\n        \"forceConsistentCasingInFileNames\": true,\n        \"declaration\": true,\n        \"declarationDir\": \"dist/types\",\n        \"outDir\": \"dist\",\n        \"rootDir\": \"src\",\n        \"sourceMap\": true,\n        \"resolveJsonModule\": true,\n        \"isolatedModules\": true,\n        \"lib\": [\"ES2022\", \"DOM\", \"DOM.Iterable\"],\n        \"types\": [\"node\"]\n    },\n    \"include\": [\"src/**/*.ts\"],\n    \"exclude\": [\"node_modules\", \"dist\", \"test\", \"cypress\", \"src/wasm\"]\n}\n"
  },
  {
    "path": "tsup.config.ts",
    "content": "import { defineConfig } from 'tsup';\nimport type { Plugin } from 'esbuild';\nimport path from 'path';\n\n/**\n * esbuild plugin that redirects resolve-loader.ts → resolve-loader.browser.ts\n * so the browser build never references the Node loader or sharp.\n */\nconst browserLoaderPlugin: Plugin = {\n    name: 'browser-loader-resolve',\n    setup(build) {\n        build.onResolve({ filter: /resolve-loader\\.js$/ }, (args) => {\n            if (args.importer && !args.path.includes('.browser')) {\n                const browserPath = path.resolve(\n                    path.dirname(args.importer),\n                    args.path\n                        .replace('resolve-loader.js', 'resolve-loader.browser.ts'),\n                );\n                return { path: browserPath };\n            }\n            return undefined;\n        });\n    },\n};\n\nexport default defineConfig([\n    // Main library (ESM + CJS, used by both browser and Node)\n    {\n        entry: {\n            index: 'src/index.ts',\n            internals: 'src/internals.ts',\n        },\n        outDir: 'dist',\n        format: ['esm', 'cjs'],\n        splitting: false,\n        dts: false,\n        sourcemap: true,\n        external: ['sharp'],\n    },\n    // Browser-specific builds (no sharp/Node loader references)\n    {\n        entry: {\n            'index.browser': 'src/index.ts',\n            'internals.browser': 'src/internals.browser.ts',\n        },\n        outDir: 'dist',\n        format: ['esm', 'cjs'],\n        splitting: false,\n        dts: false,\n        sourcemap: true,\n        external: ['sharp'],\n        esbuildPlugins: [browserLoaderPlugin],\n    },\n    // UMD/IIFE build for browsers (<script> tag)\n    {\n        entry: { 'color-thief': 'src/umd.ts' },\n        outDir: 'dist/umd',\n        format: ['iife'],\n        globalName: 'ColorThief',\n        sourcemap: true,\n        platform: 'browser',\n        external: ['sharp'],\n        minify: true,\n        esbuildPlugins: [browserLoaderPlugin],\n        esbuildOptions(options) {\n            options.external = [\n                ...(options.external || []),\n                'child_process', 'fs', 'path', 'os', 'crypto', 'stream',\n                'util', 'http', 'https', 'zlib', 'events', 'buffer',\n                'detect-libc',\n            ];\n        },\n    },\n    // CLI\n    {\n        entry: { cli: 'src/cli.ts' },\n        outDir: 'dist',\n        format: ['esm'],\n        splitting: false,\n        dts: false,\n        sourcemap: false,\n        external: ['sharp'],\n        banner: { js: '#!/usr/bin/env node' },\n    },\n    // Type declarations\n    {\n        entry: {\n            index: 'src/index.ts',\n            internals: 'src/internals.ts',\n        },\n        outDir: 'dist/types',\n        format: ['esm', 'cjs'],\n        dts: { only: true },\n    },\n    // Type declarations for browser internals\n    {\n        entry: {\n            'internals.browser': 'src/internals.browser.ts',\n        },\n        outDir: 'dist/types',\n        format: ['esm', 'cjs'],\n        dts: { only: true },\n    },\n]);\n"
  }
]