{"_id":"@bndynet/color-hub","_rev":"2-db834eff6737580f5a4c937702d66afb","name":"@bndynet/color-hub","dist-tags":{"latest":"1.0.0"},"versions":{"0.1.0":{"name":"@bndynet/color-hub","version":"0.1.0","author":{"name":"Bendy Zhang"},"license":"MIT","_id":"@bndynet/color-hub@0.1.0","maintainers":[{"name":"bndy","email":"zb@bndy.net"}],"homepage":"https://github.com/bndynet/color-hub/tree/main/packages/color-hub#readme","bugs":{"url":"https://github.com/bndynet/color-hub/issues"},"dist":{"shasum":"ed31ab27fae3043eada403c66b2aa4b5c8d1bee4","tarball":"https://registry.npmjs.org/@bndynet/color-hub/-/color-hub-0.1.0.tgz","fileCount":10,"integrity":"sha512-BohD6daR6OdZC+vj7HFnGNd460ZMwqydK2fpJMCnGDW7jpmXuIP8rWfVwkVAyNU9S2goN3k1hmeozKf+qX/LCw==","signatures":[{"sig":"MEUCIQDNHsYWyGfN+DAtKMbFyF2WHMX/lcpBAyCy7nunVP76rgIgMH7KxvnpD8XZEblMoMh2A26XzH1IL/lRFmv9fJZWi7I=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@bndynet%2fcolor-hub@0.1.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":223588},"main":"dist/index.cjs","type":"module","types":"dist/index.d.ts","module":"dist/index.js","engines":{"node":">=18"},"exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.cjs"}},"gitHead":"a963781f03b740f88bf056c5b80698bbd00d97b3","scripts":{"lint":"eslint \"src/**/*.ts\"","test":"vitest run","build":"tsup","clean":"rm -rf dist","serve":"http-server -p 3000 -o /site","start":"npm run build && concurrently \"tsup --watch\" \"npm run serve\"","lint:fix":"eslint \"src/**/*.ts\" --fix","typecheck":"tsc --noEmit","test:watch":"vitest","prepublishOnly":"npm run clean && npm run lint && npm run typecheck && npm run test && npm run build"},"_npmUser":{"name":"bndy","email":"zb@bndy.net"},"repository":{"url":"git+https://github.com/bndynet/color-hub.git","type":"git"},"_npmVersion":"11.9.0","description":"Multi-theme palettes, key-to-color assignment for charts and UI, plus default/hover/active/disabled state colors—TypeScript-first, built on colord.","directories":{},"_nodeVersion":"24.14.0","dependencies":{"colord":"^2.9.3"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.5.1","eslint":"^10.1.0","vitest":"^3.2.4","typescript":"^5.9.3","http-server":"^14.1.1","concurrently":"^9.1.2","@typescript-eslint/parser":"^8.57.2","@typescript-eslint/eslint-plugin":"^8.57.2"},"_npmOperationalInternal":{"tmp":"tmp/color-hub_0.1.0_1775029093421_0.8088113389500471","host":"s3://npm-registry-packages-npm-production"}},"1.0.0":{"name":"@bndynet/color-hub","version":"1.0.0","description":"Multi-theme palettes, key-to-color assignment for charts and UI, plus default/hover/active/disabled state colors—TypeScript-first, built on colord.","type":"module","author":{"name":"Bendy Zhang"},"repository":{"type":"git","url":"git+https://github.com/bndynet/color-hub.git"},"bugs":{"url":"https://github.com/bndynet/color-hub/issues"},"homepage":"https://github.com/bndynet/color-hub/tree/main/packages/color-hub#readme","engines":{"node":">=18"},"main":"dist/index.cjs","module":"dist/index.js","types":"dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.cjs"},"./utils":{"types":"./dist/utils.d.ts","import":"./dist/utils.js","require":"./dist/utils.cjs"},"./scale":{"types":"./dist/scale.d.ts","import":"./dist/scale.js","require":"./dist/scale.cjs"},"./harmony":{"types":"./dist/harmony.d.ts","import":"./dist/harmony.js","require":"./dist/harmony.cjs"},"./css":{"types":"./dist/css.d.ts","import":"./dist/css.js","require":"./dist/css.cjs"},"./theme-factory":{"types":"./dist/theme-factory.d.ts","import":"./dist/theme-factory.js","require":"./dist/theme-factory.cjs"},"./runtime":{"types":"./dist/runtime.d.ts","import":"./dist/runtime.js","require":"./dist/runtime.cjs"},"./cvd":{"types":"./dist/cvd.d.ts","import":"./dist/cvd.js","require":"./dist/cvd.cjs"},"./interpolate":{"types":"./dist/interpolate.d.ts","import":"./dist/interpolate.js","require":"./dist/interpolate.cjs"},"./color-hub":{"types":"./dist/color-hub.d.ts","import":"./dist/color-hub.js","require":"./dist/color-hub.cjs"},"./global":"./dist/index.global.js","./package.json":"./package.json"},"scripts":{"lint":"eslint \"src/**/*.ts\"","lint:fix":"eslint \"src/**/*.ts\" --fix","start":"npm --prefix site install && npm --prefix site run dev","site:install":"npm --prefix site install","site:build":"npm --prefix site install && npm --prefix site run build","typecheck":"tsc --noEmit","test":"vitest run","test:watch":"vitest","build":"tsup && npm run site:build","postbuild":"npm run site:build","clean":"rm -rf dist","postinstall":"npm run site:install","prepublishOnly":"npm run clean && npm run lint && npm run typecheck && npm run test && tsup"},"publishConfig":{"access":"public"},"devDependencies":{"@typescript-eslint/eslint-plugin":"^8.57.2","@typescript-eslint/parser":"^8.57.2","concurrently":"^9.1.2","eslint":"^10.1.0","http-server":"^14.1.1","tsup":"^8.5.1","typescript":"^5.9.3","vitest":"^3.2.4"},"dependencies":{"colord":"^2.9.3"},"license":"MIT","gitHead":"82629e01538053b7e0535deee5bb7a4d53135eb2","_id":"@bndynet/color-hub@1.0.0","_nodeVersion":"24.16.0","_npmVersion":"11.13.0","dist":{"integrity":"sha512-hPz9rXHmuuMeyLVz3XpW+iNimqCvM3A/6/oOSBxS5owU0f53hgtayeqaf6jO0eH9A2XCJi01YKTUgRvFzIVjfQ==","shasum":"0c8e25a3e0b35675cc436fb457157e5725a42fe5","tarball":"https://registry.npmjs.org/@bndynet/color-hub/-/color-hub-1.0.0.tgz","fileCount":74,"unpackedSize":721035,"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@bndynet%2fcolor-hub@1.0.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEQCIG7+q0uW80GkA4pzon06xXaxjszHndje/j0KtZn64MyaAiAF/IcP4AWUAvMeQ+BzwRY8M7feFEQdeNuK/pPBcyvxnQ=="}]},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:ae700552-19ed-4d11-93d0-c000a97a3407"}},"directories":{},"maintainers":[{"name":"bndy","email":"zb@bndy.net"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/color-hub_1.0.0_1781425924635_0.08994342634096975"},"_hasShrinkwrap":false}},"time":{"created":"2026-04-01T07:38:13.282Z","modified":"2026-06-14T08:32:05.191Z","0.1.0":"2026-04-01T07:38:13.550Z","1.0.0":"2026-06-14T08:32:04.828Z"},"bugs":{"url":"https://github.com/bndynet/color-hub/issues"},"author":{"name":"Bendy Zhang"},"license":"MIT","homepage":"https://github.com/bndynet/color-hub/tree/main/packages/color-hub#readme","repository":{"type":"git","url":"git+https://github.com/bndynet/color-hub.git"},"description":"Multi-theme palettes, key-to-color assignment for charts and UI, plus default/hover/active/disabled state colors—TypeScript-first, built on colord.","maintainers":[{"name":"bndy","email":"zb@bndy.net"}],"readme":"# @bndynet/color-hub\n\nA small color utility built on [colord](https://github.com/omgovich/colord). It manages multi-theme palettes, assigns colors to arbitrary keys (e.g. chart series names), and derives **default / hover / active / disabled** state colors for each base color.\n\n## Requirements\n\n- Node.js **≥ 18**\n\n## Installation\n\n```bash\nnpm install @bndynet/color-hub\n```\n\n## Quick start\n\n### ESM\n\n```ts\nimport { ColorHub, State } from '@bndynet/color-hub';\n\nconst hub = new ColorHub([\n  {\n    name: 'light',\n    palette: ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728'],\n    colorMap: {},\n  },\n]);\n\nconst series = hub.getColors('Series A');\nconsole.log(series.default, series.hover, series.active, series.disabled);\n```\n\n### CommonJS\n\n```js\nconst { ColorHub } = require('@bndynet/color-hub');\n```\n\n## Themes\n\nYou supply your own themes — the package ships no built-in color data, keeping it\nsmall and unopinionated. A theme is just a `ColorTheme` object (see **Concepts**):\n\n```ts\nimport { ColorHub } from '@bndynet/color-hub';\n\nconst hub = new ColorHub([\n  {\n    name: 'light',\n    colorMode: 'light',\n    palette: ['#2563eb', '#14b8a6', '#f97316', '#8b5cf6'],\n    colorMap: {},\n  },\n]);\nhub.switchTheme('light');\n\nconst sales = hub.getColors('Sales');\nconst profit = hub.getColors('Profit');\n\nconsole.log(sales.default, profit.default);\n```\n\n### Browser (IIFE global)\n\nThe build emits `dist/index.global.js`. Include it on the page and use the global **`ch`** object (matches the `globalName` in `tsup`):\n\n```html\n<script src=\"./node_modules/@bndynet/color-hub/dist/index.global.js\"></script>\n<script>\n  const hub = new ch.ColorHub([/* ... */]);\n</script>\n```\n\nThe IIFE bundle is also declared as the `./global` subpath export.\n\n### Deep imports (tree-shaking)\n\nEach feature module is published as a subpath export, so you can import just what\nyou need and let the bundler drop the rest:\n\n```ts\nimport { harmony } from '@bndynet/color-hub/harmony';\nimport { createInterpolator } from '@bndynet/color-hub/interpolate';\nimport { simulate } from '@bndynet/color-hub/cvd';\n```\n\nAvailable subpaths: `./utils`, `./scale`, `./harmony`, `./css`, `./theme-factory`,\n`./runtime`, `./cvd`, `./interpolate`, `./color-hub` (plus the `.` barrel and\n`./global` IIFE). Every subpath ships ESM, CJS, and type declarations.\n\n## Concepts\n\n### `ColorHub`\n\nThe constructor takes an array of **`ColorTheme`** objects and an optional second argument **`options`** (`ColorHubOptions`); the first theme is selected by default.\n\n- **`switchTheme(name)`**  \n  Switches to the named theme. If the name is missing, it falls back to the first theme and resets the palette consumption index for the active theme.\n\n- **`getCurrentTheme()`**  \n  Returns the active **`ColorTheme`** (the same object the hub updates for `colorMap` and assignments). In **`StateRecipe`** handlers, use the second argument `hub.getCurrentTheme()`.\n\n- **`getColors(key)`**  \n  Returns the base color and four state colors for string `key` (see `StateColors` below). Resolution order:\n  1. If **`colorMap[key]`** exists on the current theme, use it.\n  2. Otherwise take the next unused color from **`palette`** in order.\n  3. If the palette is exhausted, pick a new color: by default **golden-ratio** hue (`randomDistinctColor`); with **`options.paletteExhaustion: 'perceptual'`**, use **CIELAB ΔE76** spacing vs colors already assigned (`distinctColorPerceptual`).\n\n- **`appendTheme(theme)`**  \n  Appends a theme at runtime.\n\n- **`appendPalette(name, palette)`**  \n  Appends a new theme with only a name and palette (empty `colorMap`).\n\n- **`onThemeChange(listener)`**  \n  Subscribe to theme switches; the listener receives the new active theme after each `switchTheme`. Returns an unsubscribe function. (See `bindThemeToDOM` under **Runtime**.)\n\n### `ColorTheme<T>`\n\n| Field | Description |\n|--------|-------------|\n| `name` | Theme id for `switchTheme` |\n| `colorMode?` | `'light'` or `'dark'` — semantic appearance when `name` does not encode light/dark (optional) |\n| `palette?` | List of colors assigned in order to new keys |\n| `colorMap?` | Established `key → color` assignments |\n| `colors?` | Optional strongly typed named colors (`T`); read these in your app; `ColorHub` uses `palette` / `colorMap` for dynamic key assignment |\n| `stateRecipe?` | Optional per-state overrides for hover / active / disabled / focus / selected (see **StateRecipe**); overrides hub-level `options.stateRecipe` per field |\n\nGeneric example:\n\n```ts\ninterface AppColors {\n  primary: string;\n  background: string;\n}\n\nconst hub = new ColorHub<AppColors>([\n  {\n    name: 'brand-a',\n    colorMode: 'light',\n    palette: ['#2563eb', '#16a34a'],\n    colorMap: {},\n    colors: {\n      primary: '#2563eb',\n      background: '#ffffff',\n    },\n  },\n]);\n```\n\n### `State` and `StateColors`\n\nEach object returned by `getColors` includes:\n\n| Property | Meaning |\n|----------|---------|\n| `default` | Base color |\n| `hover` | Lightened vs base (more in dark mode) |\n| `active` | Darkened vs base |\n| `disabled` | Same color with alpha **0.4** |\n| `focus` | Same color with alpha **0.5** (focus ring/highlight) |\n| `selected` | Subtle fill: lightened in light mode, darkened in dark mode |\n\nDefaults are **`colorMode`-aware** (they read `getCurrentTheme().colorMode`): light mode uses `lighten 0.05` / `darken 0.1`, dark mode `lighten 0.1` / `darken 0.08`. All are computed with `lighten`, `darken`, and `alpha` (see Utilities) unless you supply a **StateRecipe** (which also accepts `focus` / `selected` and any custom state key).\n\n### `StateRecipe` and `ColorHubOptions`\n\n- **`StateRecipe`**: optional `hover` / `active` / `disabled`, each `(baseColor, hub) => string` — second argument is the **`ColorHub`** instance. Use `hub.getCurrentTheme()` for the active theme (`name`, `colorMode`, `colors`, `palette`, etc.). Omitted states keep the built-in defaults above.\n- **Merge order**: built-in defaults → `new ColorHub(themes, { stateRecipe })` → current theme’s `stateRecipe` (theme wins per field).\n- **`assignment`**: `'sequential'` (default) or `'hash'`. `sequential` takes the next unused palette color in request order. `hash` uses a deterministic `hash(key) → palette index`, so the **same key always maps to the same color regardless of request order** — ideal for keeping chart-series colors stable across renders/reloads (collisions may reuse a color).\n- **`paletteExhaustion`**: `'golden'` (default) or `'perceptual'` — when the theme `palette` is exhausted, use golden-ratio hue or **CIELAB ΔE76** spacing (`distinctColorPerceptual`). Tune with **`perceptualMinDeltaE`** (default ~23) and **`perceptualMaxAttempts`** (default 100). (Applies to `sequential` assignment; `hash` always picks from the palette when it is non-empty.)\n\n```ts\n// Stable, order-independent colors for chart series\nconst hub = new ColorHub(\n  [{ name: 'light', palette: ['#2563eb', '#14b8a6', '#f97316'], colorMap: {} }],\n  { assignment: 'hash' },\n);\nhub.switchTheme('light');\nhub.getColors('Sales').default; // same color no matter when 'Sales' is requested\n```\n\n```ts\nimport { ColorHub, lighten, darken } from '@bndynet/color-hub';\n\nconst hub = new ColorHub(\n  [{ name: 'brand', colorMode: 'light', palette: ['#2563eb'], colorMap: {} }],\n  {\n    stateRecipe: {\n      hover: (base, hub) =>\n        hub.getCurrentTheme().colorMode === 'dark'\n          ? darken(base, 0.08)\n          : lighten(base, 0.12),\n    },\n  },\n);\n```\n\n## Utilities (`utils`)\n\nAll transform helpers accept CSS-parseable color strings and return **hex** strings (`colord`’s `toHex()`), unless noted.\n\n### Color adjustments\n\n| Function | Description |\n|----------|-------------|\n| `alpha(color, a)` | Set alpha channel (`a` 0–1); hex may include alpha (`#rrggbbaa`) when needed |\n| `lighten(color, amount)` | Lighten in HSL |\n| `darken(color, amount)` | Darken in HSL |\n| `saturate(color, amount)` | Increase saturation |\n| `desaturate(color, amount)` | Decrease saturation |\n| `invert(color)` | Mirror HSL lightness (`l → 100 - l`), keep hue/saturation — light↔dark adaptation |\n| `grayscale(color)` | Same lightness, zero saturation |\n| `rotateHue(color, degrees)` | Rotate hue on the wheel (e.g. `180` for complementary) |\n\n### Mixing and ramps\n\n| Function | Description |\n|----------|-------------|\n| `mix(color1, color2, ratio?)` | **sRGB channel** linear interpolation (gamma-encoded values); `ratio` 0 → `color1`, 1 → `color2` (default `0.5`). Fast and predictable; midpoints are not perceptually uniform. |\n| `colorSteps(from, to, steps)` | Evenly spaced `mix` samples from `from` to `to` inclusive; `steps` ≥ 2 |\n| `mixOklab(color1, color2, ratio?)` | Interpolate in **Oklab** (L, a, b linear), like CSS `color-mix(in oklab, …)`. Smoother, less “muddy gray” midpoints than sRGB `mix` or than polar OKLCH interpolation. |\n| `mixOklch(color1, color2, ratio?)` | **Alias of `mixOklab`** (same implementation). |\n| `colorStepsOklch(from, to, steps)` | Evenly spaced mixes using **`mixOklab`** (name kept for compatibility). |\n| `colorStepsOklab(from, to, steps)` | **Alias of `colorStepsOklch`**. |\n\n### Contrast and readability\n\n| Function | Description |\n|----------|-------------|\n| `contrastRatio(foreground, background)` | WCAG 2.x contrast ratio (1–21) |\n| `contrastText(background, options?)` | Returns `light` or `dark` text color (defaults `#ffffff` / `#000000`) using perceived brightness |\n| `contrastThreshold(level?, size?)` | Minimum WCAG ratio for `'AA'`/`'AAA'` × `'normal'`/`'large'` (e.g. AA normal → 4.5) |\n| `isAccessible(fg, bg, target?)` | Whether `fg` on `bg` meets a WCAG threshold. `target`: `{ level?, size?, ratio? }` (default AA / normal) |\n| `ensureContrast(fg, bg, target?)` | Adjusts `fg` lightness (keeping hue/sat) until it meets the target ratio; falls back to black/white |\n| `brightness(color)` | Perceived brightness 0–1 (colord / WCAG-derived) |\n| `isDark(color)` / `isLight(color)` | Convenience wrappers around colord |\n\n```ts\nimport { isAccessible, ensureContrast } from '@bndynet/color-hub';\n\nisAccessible('#777', '#fff');                 // false (AA normal needs ≥ 4.5)\nensureContrast('#9bbcff', '#ffffff');         // darkened until ≥ 4.5:1 on white\nensureContrast('#1f3a8a', '#000', { level: 'AAA' }); // lightened until ≥ 7:1\n```\n\n### Tonal scales\n\n| Function | Description |\n|----------|-------------|\n| `generateScale(base, options?)` | Build an 11-stop scale (`50`–`950`) from one color. The base anchors `500`; lighter stops blend toward white and darker stops toward black in **Oklab** (hue-stable, perceptually smooth). `options.mix` overrides per-stop blend amounts. |\n\n```ts\nimport { generateScale } from '@bndynet/color-hub';\n\nconst blue = generateScale('#2563eb');\nblue[50];  // very light tint\nblue[500]; // ≈ '#2563eb' (the base)\nblue[900]; // deep shade\n```\n\n### Color harmony\n\n| Function | Description |\n|----------|-------------|\n| `harmony(base, scheme, options?)` | Dispatch by `scheme` (see below). `options`: `{ angle?, count? }` |\n| `complementary(base)` | `[base, +180°]` |\n| `analogous(base, angle?)` | `[base, +angle, -angle]` (default `30`) |\n| `triadic(base)` | three colors 120° apart |\n| `tetradic(base)` | rectangle: `[base, +60°, +180°, +240°]` |\n| `splitComplementary(base)` | `[base, +150°, +210°]` |\n| `monochromatic(base, count?)` | `count` shades of one hue from `generateScale` (default `5`, clamped 2–11) |\n\n```ts\nimport { harmony, triadic } from '@bndynet/color-hub';\n\ntriadic('#2563eb');                 // ['#2563eb', '#eb2563', '#63eb25'] (approx)\nharmony('#2563eb', 'analogous', { angle: 45 });\n```\n\n### Interpolation / continuous scales\n\nMap a continuous value to a color over **your own** stop colors (the package ships\nno built-in scales). Useful for heatmaps, choropleth maps, and value→color\nmappings. Interpolation defaults to **Oklab** (perceptually smooth); pass\n`{ space: 'srgb' }` for raw channel mixing.\n\n| Function | Description |\n|----------|-------------|\n| `createInterpolator(stops, options?)` | Returns `(t) => hex` for `t` in `[0, 1]` (clamped), stops spread evenly. `options`: `{ space?: 'oklab' \\| 'srgb' }` |\n| `sample(stops, n, options?)` | `n` evenly spaced samples (inclusive endpoints); generalizes `colorSteps` to more than two stops |\n| `createDivergingInterpolator(low, mid, high, options?)` | `(t) => hex` with `mid` fixed at `t = 0.5` — for signed data (e.g. -1…+1) |\n\n```ts\nimport { createInterpolator, createDivergingInterpolator, sample } from '@bndynet/color-hub';\n\nconst heat = createInterpolator(['#2563eb', '#facc15', '#dc2626']);\nheat(0);   // ≈ '#2563eb'\nheat(0.5); // ≈ '#facc15'\nheat(1);   // ≈ '#dc2626'\n\nconst corr = createDivergingInterpolator('#2563eb', '#f8fafc', '#dc2626');\ncorr(0.5); // ≈ '#f8fafc' (neutral midpoint)\n\nsample(['#2563eb', '#dc2626'], 5); // 5 colors, inclusive endpoints\n```\n\n### Theme generation\n\n| Function | Description |\n|----------|-------------|\n| `createThemeFromColor(base, options?)` | From one brand color, build `{ light, dark }` themes. The base anchors `palette[0]` (light); other colors spread by the golden angle. `options`: `{ name?, paletteSize?, saturation? }`. Semantic tokens are left to you. |\n| `deriveDarkTheme(theme)` | Flip `colorMode` to `dark`, mirror named `colors` lightness (`invert`), and lift palette colors for dark backgrounds. Clones `colorMap`. |\n\n```ts\nimport { ColorHub, createThemeFromColor } from '@bndynet/color-hub';\n\nconst { light, dark } = createThemeFromColor('#2563eb', {\n  name: 'brand',\n  paletteSize: 8,\n});\nconst hub = new ColorHub([light, dark]); // 'brand-light' / 'brand-dark'\n```\n\n### CSS variables / theme output\n\n| Function | Description |\n|----------|-------------|\n| `toCSSVariables(theme, options?)` | Map a theme's `colors` (camelCase → kebab-case) to `{ '--ch-grid': '#...' }`. `options`: `{ prefix?, includePalette?, includeColorMap? }` |\n| `toCSSString(theme, options?)` | Render the variables as an injectable CSS rule. Adds `selector?` (default `:root`) |\n\n```ts\nimport { toCSSString } from '@bndynet/color-hub';\n\nconst darkTheme = {\n  name: 'dark',\n  colorMode: 'dark' as const,\n  colors: { background: '#020617', textPrimary: '#e2e8f0' },\n  palette: ['#60a5fa', '#2dd4bf'],\n};\n\ntoCSSString(darkTheme, {\n  selector: '[data-theme=\"dark\"]',\n  includePalette: true,\n});\n// [data-theme=\"dark\"] {\n//   --ch-background: #020617;\n//   --ch-text-primary: #e2e8f0;\n//   --ch-palette-0: #60a5fa;\n//   --ch-palette-1: #2dd4bf;\n// }\n```\n\n### Runtime (browser)\n\nHelpers to apply themes to the DOM and react to the system color scheme. All\nfeature-detect their globals, so importing them is safe in Node / SSR (they become\nno-ops there).\n\n| Function | Description |\n|----------|-------------|\n| `applyTheme(theme, options?)` | Write the theme's CSS variables on an element (default `document.documentElement`) and set `data-theme` to the theme name. `options` extend `toCSSVariables` with `{ target?, attribute? }` (`attribute: null` to skip) |\n| `getSystemColorScheme()` | `'light'` / `'dark'` from `prefers-color-scheme` (`'light'` when unknown) |\n| `watchSystemColorScheme(cb)` | Subscribe to OS scheme changes; returns an unsubscribe function |\n| `persistThemeName(name, key?)` | Save the active theme name to `localStorage` (default key `color-hub-theme`) |\n| `loadThemeName(key?)` | Read a persisted theme name, or `null` |\n| `bindThemeToDOM(hub, options?)` | Apply the hub's current theme now and on every `switchTheme`; `options.persist` saves the name. Returns an unsubscribe function |\n\nApply a single theme directly — by default it writes onto `:root`, or pass\n`target` to scope the variables to one element (and `prefix` / `includePalette`\nto customize the output):\n\n```ts\nimport { applyTheme } from '@bndynet/color-hub';\n\n// Write --ch-* vars + data-theme on <html> (document.documentElement):\napplyTheme(darkTheme);\n\n// ...or scope the variables to a specific element, with a custom prefix:\nconst card = document.querySelector<HTMLElement>('.card')!;\napplyTheme(darkTheme, {\n  target: card,\n  prefix: 'app',        // --app-background, --app-text-primary, ...\n  includePalette: true, // also emit --app-palette-0, --app-palette-1, ...\n  attribute: null,      // skip the data-theme attribute\n});\n```\n\nOr bind a `ColorHub` so the DOM re-themes automatically on every `switchTheme`:\n\n```ts\nimport { ColorHub, bindThemeToDOM, getSystemColorScheme } from '@bndynet/color-hub';\n\nconst hub = new ColorHub([light, dark]);\nconst unbind = bindThemeToDOM(hub, { persist: true });\nhub.switchTheme(getSystemColorScheme() === 'dark' ? 'brand-dark' : 'brand-light');\n// later: unbind();\n```\n\n### Parsing and conversion\n\n| Function | Description |\n|----------|-------------|\n| `isValidColor(input)` | Whether the string/object parses as a color |\n| `toRgb(color)` | `{ r, g, b, a }` for canvas/CSS (`a` 0–1) |\n| `toHsl(color)` | `{ h, s, l, a }` (`h` 0–360, `s`/`l` 0–100, `a` 0–1) |\n| `toHslString(color)` | CSS `hsl(...)` / `hsla(...)` string |\n| `toOklab(color)` | `{ l, a, b }` OKLab coordinates (`l` ≈ 0–1) |\n| `toOklch(color)` | `{ l, c, h, alpha }` OKLCH (`c` ≥ 0, `h` 0–360; achromatic → `h = 0`) |\n| `toOklchString(color, precision?)` | CSS `oklch(L C H)` / `oklch(L C H / a)` (alpha omitted when opaque); modern browsers render this natively |\n\n```ts\nimport { toOklchString } from '@bndynet/color-hub';\n\ntoOklchString('#ff0000');   // 'oklch(0.628 0.2577 29.2339)'\ntoOklchString('#ff000080'); // 'oklch(0.628 0.2577 29.2339 / 0.5)'\n```\n\n### Perceptual distance (pairwise separation)\n\n| Function | Description |\n|----------|-------------|\n| `deltaE76(color1, color2)` | CIELAB ΔE76 (Euclidean in L*a*b*); no extra deps |\n| `minDeltaE76ToExisting(candidate, existing[])` | Minimum ΔE76 from `candidate` to any color in `existing` |\n| `deltaEOK(color1, color2)` | **OKLab** ΔEOK (Euclidean in OKLab); more perceptually uniform than ΔE76. Note the **smaller scale** — black↔white ≈ `1.0`, not ~100 |\n| `minDeltaEOKToExisting(candidate, existing[])` | Minimum ΔEOK from `candidate` to any color in `existing` |\n| `distinctColorPerceptual(existing[], options?)` | Sample hues until the min distance ≥ `minDeltaE` or fallback. `options.metric`: `'de76'` (default, threshold ~23) or `'deOK'` (threshold ~0.08) |\n\nFor **CIEDE2000**-based picking, use a library such as [culori](https://github.com/Evercoder/culori) in your app and pass the result into `palette` / `colorMap`. Publication-grade **colorblind-safe** palettes (e.g. Paul Tol) are best applied as explicit `palette` arrays rather than generated hues alone.\n\n### Color-vision-deficiency (CVD) simulation\n\nSimulate how a color is perceived under **protanopia**, **deuteranopia**, or\n**tritanopia** (Machado et al. 2009 model). The intended use is a **palette\nrobustness check**: simulate the colors you assigned to chart series, then run\n`deltaE76` / `deltaEOK` on the results to find pairs that collapse (become hard to\ntell apart) and adjust your palette.\n\n| Function | Description |\n|----------|-------------|\n| `simulate(color, type)` | `type`: `'protanopia' \\| 'deuteranopia' \\| 'tritanopia'` → simulated hex (alpha preserved) |\n| `simulateAll(color)` | `{ protanopia, deuteranopia, tritanopia }` |\n\n```ts\nimport { simulate, simulateAll, deltaEOK } from '@bndynet/color-hub';\n\nsimulate('#ff0000', 'deuteranopia'); // shifts toward olive/yellow\n\n// Are two series colors still distinguishable for deuteranopes?\nconst a = '#d62728';\nconst b = '#2ca02c';\nconst safe = deltaEOK(simulate(a, 'deuteranopia'), simulate(b, 'deuteranopia')) > 0.1;\n```\n\n### Random helpers\n\n| Function | Description |\n|----------|-------------|\n| `randomColor()` | Fully random 24-bit hex (quick prototypes) |\n| `randomChartColor(saturation?, lightness?)` | Random hue with fixed S/L (good for charts) |\n| `randomDistinctColor()` | Golden-ratio hue step (many distinguishable series) |\n\n```ts\nimport {\n  lighten,\n  mix,\n  mixOklab,\n  mixOklch,\n  colorStepsOklch,\n  colorStepsOklab,\n  contrastText,\n  randomDistinctColor,\n} from '@bndynet/color-hub';\n```\n\n## Development\n\n```bash\nnpm install          # install dependencies\nnpm run build        # writes dist/ (ESM, CJS, IIFE, declarations)\nnpm run typecheck    # tsc\nnpm run test         # vitest\nnpm run lint\n```\n\nA local docs site with live demos lives under [`site/`](./site); run it with\n`npm start`.\n\n## License\n\nMIT\n","readmeFilename":"README.md"}