@ankhorage/color-theory 0.0.7 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @ankhorage/color-theory
2
2
 
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - be8317b: Add public contrast measurement, caller-defined foreground selection, and deterministic swatch-step selection with perceptual and contrast diagnostics.
8
+
9
+ ## 0.0.8
10
+
11
+ ### Patch Changes
12
+
13
+ - cd67ac5: Align generated neutral key colors with the swatch base-step lightness so adjacent light neutral steps remain distinct.
14
+
3
15
  ## 0.0.7
4
16
 
5
17
  ### Patch Changes
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  # @ankhorage/color-theory
5
5
 
6
- ![license: MIT](././paradox/badges/license.svg) ![npm: v0.0.6](././paradox/badges/npm.svg) ![runtime: bun](././paradox/badges/runtime.svg) ![typescript: strict](././paradox/badges/typescript.svg) ![eslint: checked](././paradox/badges/eslint.svg) ![prettier: checked](././paradox/badges/prettier.svg) ![build: checked](././paradox/badges/build.svg) ![tests: checked](././paradox/badges/tests.svg) ![docs: paradox](././paradox/badges/docs.svg)
6
+ ![license: MIT](././paradox/badges/license.svg) ![npm: v0.0.8](././paradox/badges/npm.svg) ![runtime: bun](././paradox/badges/runtime.svg) ![typescript: strict](././paradox/badges/typescript.svg) ![eslint: checked](././paradox/badges/eslint.svg) ![prettier: checked](././paradox/badges/prettier.svg) ![build: checked](././paradox/badges/build.svg) ![tests: checked](././paradox/badges/tests.svg) ![docs: paradox](././paradox/badges/docs.svg)
7
7
 
8
8
  Standalone color theory, harmony, swatch, contrast, and theme color generation utilities.
9
9
 
@@ -16,62 +16,12 @@ Standalone color theory, harmony, swatch, contrast, and theme color generation u
16
16
  - [Module relationships](././paradox/diagrams/module-relationships.mmd)
17
17
  - [Export graph](././paradox/diagrams/export-graph.mmd)
18
18
  - [assertHexColor sequence](././paradox/diagrams/sequences/assert-hex-color.mmd)
19
+ - [createDefaultSemanticStatusSwatches sequence](././paradox/diagrams/sequences/create-default-semantic-status-swatches.mmd)
20
+ - [createSemanticStatusSwatches sequence](././paradox/diagrams/sequences/create-semantic-status-swatches.mmd)
21
+ - [generateColorSwatch sequence](././paradox/diagrams/sequences/generate-color-swatch.mmd)
22
+ - [getContrastRatio sequence](././paradox/diagrams/sequences/get-contrast-ratio.mmd)
19
23
  - [getReadableForeground sequence](././paradox/diagrams/sequences/get-readable-foreground.mmd)
20
24
  - [getThemeModePrimaryHex sequence](././paradox/diagrams/sequences/get-theme-mode-primary-hex.mmd)
21
25
  - [parseHexColorOrThrow sequence](././paradox/diagrams/sequences/parse-hex-color-or-throw.mmd)
22
-
23
- ## Architecture preview
24
-
25
- <details>
26
- <summary>Architecture overview</summary>
27
-
28
- ```mermaid
29
- graph TD
30
- package__ankhorage_color_theory["@ankhorage/color-theory"]
31
- entrypoint_src_index_ts["src/index.ts"]
32
- package__ankhorage_color_theory --> entrypoint_src_index_ts
33
- module_src_contrast_ts["src/contrast.ts"]
34
- package__ankhorage_color_theory -.-> module_src_contrast_ts
35
- module_src_contrast_ts --> module_src_hex_ts
36
- module_src_contrast_ts --> module_src_internal_culori_ts
37
- module_src_culori_fn_ts["src/culori-fn.ts"]
38
- package__ankhorage_color_theory -.-> module_src_culori_fn_ts
39
- module_src_culori_ts["src/culori.ts"]
40
- package__ankhorage_color_theory -.-> module_src_culori_ts
41
- module_src_harmony_ts["src/harmony.ts"]
42
- package__ankhorage_color_theory -.-> module_src_harmony_ts
43
- module_src_harmony_ts --> module_src_hex_ts
44
- module_src_harmony_ts --> module_src_internal_culori_ts
45
- module_src_hex_ts["src/hex.ts"]
46
- package__ankhorage_color_theory -.-> module_src_hex_ts
47
- module_src_index_ts["src/index.ts"]
48
- module_src_internal_culori_ts["src/internal-culori.ts"]
49
- package__ankhorage_color_theory -.-> module_src_internal_culori_ts
50
- module_src_internal_culori_ts --> module_src_hex_ts
51
- module_src_neutral_ts["src/neutral.ts"]
52
- package__ankhorage_color_theory -.-> module_src_neutral_ts
53
- module_src_neutral_ts --> module_src_harmony_ts
54
- module_src_neutral_ts --> module_src_hex_ts
55
- module_src_neutral_ts --> module_src_internal_culori_ts
56
- module_src_neutral_ts --> module_src_swatches_ts
57
- module_src_semantic_status_ts["src/semantic-status.ts"]
58
- package__ankhorage_color_theory -.-> module_src_semantic_status_ts
59
- module_src_semantic_status_ts --> module_src_hex_ts
60
- module_src_semantic_status_ts --> module_src_swatches_ts
61
- module_src_semantics_ts["src/semantics.ts"]
62
- package__ankhorage_color_theory -.-> module_src_semantics_ts
63
- module_src_semantics_ts --> module_src_harmony_ts
64
- module_src_semantics_ts --> module_src_swatches_ts
65
- module_src_swatches_ts["src/swatches.ts"]
66
- package__ankhorage_color_theory -.-> module_src_swatches_ts
67
- module_src_swatches_ts --> module_src_hex_ts
68
- module_src_swatches_ts --> module_src_internal_culori_ts
69
- module_src_theme_colors_ts["src/theme-colors.ts"]
70
- package__ankhorage_color_theory -.-> module_src_theme_colors_ts
71
- module_src_theme_colors_ts --> module_src_harmony_ts
72
- module_src_theme_colors_ts --> module_src_hex_ts
73
- module_src_theme_colors_ts --> module_src_neutral_ts
74
- module_src_theme_colors_ts --> module_src_swatches_ts
75
- ```
76
-
77
- </details>
26
+ - [selectColorSwatchStep sequence](././paradox/diagrams/sequences/select-color-swatch-step.mmd)
27
+ - [selectReadableForeground sequence](././paradox/diagrams/sequences/select-readable-foreground.mmd)
@@ -3,6 +3,31 @@ export interface ReadableForegroundResult {
3
3
  foreground: HexColor;
4
4
  contrast: number;
5
5
  }
6
+ export type ForegroundTiePolicy = 'first' | 'last';
7
+ export interface ForegroundSelectionCandidate {
8
+ foreground: HexColor;
9
+ contrast: number;
10
+ passesMinimum: boolean;
11
+ selected: boolean;
12
+ rejectionReason?: 'minimum-contrast' | 'weaker-contrast' | 'tie-break';
13
+ }
14
+ export interface ForegroundSelectionResult {
15
+ selected: ForegroundSelectionCandidate | null;
16
+ candidates: readonly ForegroundSelectionCandidate[];
17
+ minimumContrast: number;
18
+ tieBreak: {
19
+ policy: ForegroundTiePolicy;
20
+ applied: boolean;
21
+ };
22
+ }
23
+ /***
24
+ Measure the WCAG contrast ratio between two validated hex colors.
25
+ */
26
+ export declare function getContrastRatio(colorA: HexColor, colorB: HexColor): number;
27
+ /***
28
+ Select the strongest caller-provided foreground that meets a minimum WCAG contrast ratio.
29
+ */
30
+ export declare function selectReadableForeground(background: HexColor, foregrounds: readonly HexColor[], minimumContrast: number, tiePolicy: ForegroundTiePolicy): ForegroundSelectionResult;
6
31
  /***
7
32
  Return the readable black or white foreground color with the stronger contrast against a background color.
8
33
  */
@@ -1 +1 @@
1
- {"version":3,"file":"contrast.d.ts","sourceRoot":"","sources":["../src/contrast.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAItC,MAAM,WAAW,wBAAwB;IACvC,UAAU,EAAE,QAAQ,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAKD;;EAEE;AACF,wBAAgB,qBAAqB,CAAC,UAAU,EAAE,QAAQ,GAAG,wBAAwB,CASpF"}
1
+ {"version":3,"file":"contrast.d.ts","sourceRoot":"","sources":["../src/contrast.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAItC,MAAM,WAAW,wBAAwB;IACvC,UAAU,EAAE,QAAQ,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,MAAM,mBAAmB,GAAG,OAAO,GAAG,MAAM,CAAC;AAEnD,MAAM,WAAW,4BAA4B;IAC3C,UAAU,EAAE,QAAQ,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,OAAO,CAAC;IACvB,QAAQ,EAAE,OAAO,CAAC;IAClB,eAAe,CAAC,EAAE,kBAAkB,GAAG,iBAAiB,GAAG,WAAW,CAAC;CACxE;AAED,MAAM,WAAW,yBAAyB;IACxC,QAAQ,EAAE,4BAA4B,GAAG,IAAI,CAAC;IAC9C,UAAU,EAAE,SAAS,4BAA4B,EAAE,CAAC;IACpD,eAAe,EAAE,MAAM,CAAC;IACxB,QAAQ,EAAE;QACR,MAAM,EAAE,mBAAmB,CAAC;QAC5B,OAAO,EAAE,OAAO,CAAC;KAClB,CAAC;CACH;AAKD;;EAEE;AACF,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,QAAQ,GAAG,MAAM,CAE3E;AAED;;EAEE;AACF,wBAAgB,wBAAwB,CACtC,UAAU,EAAE,QAAQ,EACpB,WAAW,EAAE,SAAS,QAAQ,EAAE,EAChC,eAAe,EAAE,MAAM,EACvB,SAAS,EAAE,mBAAmB,GAC7B,yBAAyB,CA8C3B;AAED;;EAEE;AACF,wBAAgB,qBAAqB,CAAC,UAAU,EAAE,QAAQ,GAAG,wBAAwB,CAUpF"}
package/dist/contrast.js CHANGED
@@ -2,15 +2,84 @@ import { parseHexColorOrThrow } from './hex';
2
2
  import { contrastRatio } from './internal-culori';
3
3
  const BLACK = parseHexColorOrThrow('#000000');
4
4
  const WHITE = parseHexColorOrThrow('#FFFFFF');
5
+ /***
6
+ Measure the WCAG contrast ratio between two validated hex colors.
7
+ */
8
+ export function getContrastRatio(colorA, colorB) {
9
+ return contrastRatio(colorA, colorB);
10
+ }
11
+ /***
12
+ Select the strongest caller-provided foreground that meets a minimum WCAG contrast ratio.
13
+ */
14
+ export function selectReadableForeground(background, foregrounds, minimumContrast, tiePolicy) {
15
+ assertMinimumContrast(minimumContrast);
16
+ if (foregrounds.length === 0) {
17
+ throw new Error('[color-theory] Foreground candidates must not be empty.');
18
+ }
19
+ const normalizedBackground = parseHexColorOrThrow(background);
20
+ const measured = foregrounds.map((foreground, index) => {
21
+ const normalizedForeground = parseHexColorOrThrow(foreground);
22
+ return {
23
+ foreground: normalizedForeground,
24
+ contrast: getContrastRatio(normalizedBackground, normalizedForeground),
25
+ index,
26
+ };
27
+ });
28
+ const passing = measured.filter(({ contrast }) => contrast >= minimumContrast);
29
+ const strongestContrast = Math.max(...passing.map(({ contrast }) => contrast));
30
+ const strongest = passing.filter(({ contrast }) => contrast === strongestContrast);
31
+ const selectedMeasured = tiePolicy === 'first' ? strongest.at(0) : strongest.at(-1);
32
+ const tieBreakApplied = strongest.length > 1;
33
+ const candidates = measured.map(({ foreground, contrast, index }) => {
34
+ const selected = selectedMeasured?.index === index;
35
+ return {
36
+ foreground,
37
+ contrast,
38
+ passesMinimum: contrast >= minimumContrast,
39
+ selected,
40
+ ...(!selected
41
+ ? {
42
+ rejectionReason: getForegroundRejectionReason(contrast, minimumContrast, strongestContrast),
43
+ }
44
+ : {}),
45
+ };
46
+ });
47
+ return {
48
+ selected: candidates.find(({ selected }) => selected) ?? null,
49
+ candidates,
50
+ minimumContrast,
51
+ tieBreak: { policy: tiePolicy, applied: tieBreakApplied },
52
+ };
53
+ }
5
54
  /***
6
55
  Return the readable black or white foreground color with the stronger contrast against a background color.
7
56
  */
8
57
  export function getReadableForeground(background) {
9
- const contrastOnBlack = contrastRatio(background, BLACK);
10
- const contrastOnWhite = contrastRatio(background, WHITE);
11
- if (contrastOnBlack >= contrastOnWhite) {
12
- return { foreground: BLACK, contrast: contrastOnBlack };
58
+ const result = selectReadableForeground(background, [BLACK, WHITE], 1, 'first');
59
+ if (!result.selected) {
60
+ throw new Error('[color-theory] Expected black or white to be a readable foreground.');
13
61
  }
14
- return { foreground: WHITE, contrast: contrastOnWhite };
62
+ return {
63
+ foreground: result.selected.foreground,
64
+ contrast: result.selected.contrast,
65
+ };
66
+ }
67
+ /***
68
+ Validate a WCAG contrast threshold.
69
+ */
70
+ function assertMinimumContrast(minimumContrast) {
71
+ if (!Number.isFinite(minimumContrast) || minimumContrast < 1 || minimumContrast > 21) {
72
+ throw new Error(`[color-theory] Minimum contrast must be a finite ratio from 1 through 21: ${minimumContrast}.`);
73
+ }
74
+ }
75
+ /***
76
+ Explain why a foreground candidate was not selected.
77
+ */
78
+ function getForegroundRejectionReason(contrast, minimumContrast, strongestContrast) {
79
+ if (contrast < minimumContrast)
80
+ return 'minimum-contrast';
81
+ if (contrast < strongestContrast)
82
+ return 'weaker-contrast';
83
+ return 'tie-break';
15
84
  }
16
85
  //# sourceMappingURL=contrast.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"contrast.js","sourceRoot":"","sources":["../src/contrast.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,oBAAoB,EAAE,MAAM,OAAO,CAAC;AAC7C,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAOlD,MAAM,KAAK,GAAG,oBAAoB,CAAC,SAAS,CAAC,CAAC;AAC9C,MAAM,KAAK,GAAG,oBAAoB,CAAC,SAAS,CAAC,CAAC;AAE9C;;EAEE;AACF,MAAM,UAAU,qBAAqB,CAAC,UAAoB;IACxD,MAAM,eAAe,GAAG,aAAa,CAAC,UAAU,EAAE,KAAK,CAAC,CAAC;IACzD,MAAM,eAAe,GAAG,aAAa,CAAC,UAAU,EAAE,KAAK,CAAC,CAAC;IAEzD,IAAI,eAAe,IAAI,eAAe,EAAE,CAAC;QACvC,OAAO,EAAE,UAAU,EAAE,KAAK,EAAE,QAAQ,EAAE,eAAe,EAAE,CAAC;IAC1D,CAAC;IAED,OAAO,EAAE,UAAU,EAAE,KAAK,EAAE,QAAQ,EAAE,eAAe,EAAE,CAAC;AAC1D,CAAC","sourcesContent":["import type { HexColor } from './hex';\nimport { parseHexColorOrThrow } from './hex';\nimport { contrastRatio } from './internal-culori';\n\nexport interface ReadableForegroundResult {\n foreground: HexColor;\n contrast: number;\n}\n\nconst BLACK = parseHexColorOrThrow('#000000');\nconst WHITE = parseHexColorOrThrow('#FFFFFF');\n\n/***\n Return the readable black or white foreground color with the stronger contrast against a background color.\n*/\nexport function getReadableForeground(background: HexColor): ReadableForegroundResult {\n const contrastOnBlack = contrastRatio(background, BLACK);\n const contrastOnWhite = contrastRatio(background, WHITE);\n\n if (contrastOnBlack >= contrastOnWhite) {\n return { foreground: BLACK, contrast: contrastOnBlack };\n }\n\n return { foreground: WHITE, contrast: contrastOnWhite };\n}\n"]}
1
+ {"version":3,"file":"contrast.js","sourceRoot":"","sources":["../src/contrast.ts"],"names":[],"mappings":"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","sourcesContent":["import type { HexColor } from './hex';\nimport { parseHexColorOrThrow } from './hex';\nimport { contrastRatio } from './internal-culori';\n\nexport interface ReadableForegroundResult {\n foreground: HexColor;\n contrast: number;\n}\n\nexport type ForegroundTiePolicy = 'first' | 'last';\n\nexport interface ForegroundSelectionCandidate {\n foreground: HexColor;\n contrast: number;\n passesMinimum: boolean;\n selected: boolean;\n rejectionReason?: 'minimum-contrast' | 'weaker-contrast' | 'tie-break';\n}\n\nexport interface ForegroundSelectionResult {\n selected: ForegroundSelectionCandidate | null;\n candidates: readonly ForegroundSelectionCandidate[];\n minimumContrast: number;\n tieBreak: {\n policy: ForegroundTiePolicy;\n applied: boolean;\n };\n}\n\nconst BLACK = parseHexColorOrThrow('#000000');\nconst WHITE = parseHexColorOrThrow('#FFFFFF');\n\n/***\n Measure the WCAG contrast ratio between two validated hex colors.\n*/\nexport function getContrastRatio(colorA: HexColor, colorB: HexColor): number {\n return contrastRatio(colorA, colorB);\n}\n\n/***\n Select the strongest caller-provided foreground that meets a minimum WCAG contrast ratio.\n*/\nexport function selectReadableForeground(\n background: HexColor,\n foregrounds: readonly HexColor[],\n minimumContrast: number,\n tiePolicy: ForegroundTiePolicy,\n): ForegroundSelectionResult {\n assertMinimumContrast(minimumContrast);\n if (foregrounds.length === 0) {\n throw new Error('[color-theory] Foreground candidates must not be empty.');\n }\n\n const normalizedBackground = parseHexColorOrThrow(background);\n const measured = foregrounds.map((foreground, index) => {\n const normalizedForeground = parseHexColorOrThrow(foreground);\n return {\n foreground: normalizedForeground,\n contrast: getContrastRatio(normalizedBackground, normalizedForeground),\n index,\n };\n });\n const passing = measured.filter(({ contrast }) => contrast >= minimumContrast);\n const strongestContrast = Math.max(...passing.map(({ contrast }) => contrast));\n const strongest = passing.filter(({ contrast }) => contrast === strongestContrast);\n const selectedMeasured = tiePolicy === 'first' ? strongest.at(0) : strongest.at(-1);\n const tieBreakApplied = strongest.length > 1;\n\n const candidates = measured.map(({ foreground, contrast, index }) => {\n const selected = selectedMeasured?.index === index;\n return {\n foreground,\n contrast,\n passesMinimum: contrast >= minimumContrast,\n selected,\n ...(!selected\n ? {\n rejectionReason: getForegroundRejectionReason(\n contrast,\n minimumContrast,\n strongestContrast,\n ),\n }\n : {}),\n } satisfies ForegroundSelectionCandidate;\n });\n\n return {\n selected: candidates.find(({ selected }) => selected) ?? null,\n candidates,\n minimumContrast,\n tieBreak: { policy: tiePolicy, applied: tieBreakApplied },\n };\n}\n\n/***\n Return the readable black or white foreground color with the stronger contrast against a background color.\n*/\nexport function getReadableForeground(background: HexColor): ReadableForegroundResult {\n const result = selectReadableForeground(background, [BLACK, WHITE], 1, 'first');\n if (!result.selected) {\n throw new Error('[color-theory] Expected black or white to be a readable foreground.');\n }\n\n return {\n foreground: result.selected.foreground,\n contrast: result.selected.contrast,\n };\n}\n\n/***\n Validate a WCAG contrast threshold.\n*/\nfunction assertMinimumContrast(minimumContrast: number): void {\n if (!Number.isFinite(minimumContrast) || minimumContrast < 1 || minimumContrast > 21) {\n throw new Error(\n `[color-theory] Minimum contrast must be a finite ratio from 1 through 21: ${minimumContrast}.`,\n );\n }\n}\n\n/***\n Explain why a foreground candidate was not selected.\n*/\nfunction getForegroundRejectionReason(\n contrast: number,\n minimumContrast: number,\n strongestContrast: number,\n): NonNullable<ForegroundSelectionCandidate['rejectionReason']> {\n if (contrast < minimumContrast) return 'minimum-contrast';\n if (contrast < strongestContrast) return 'weaker-contrast';\n return 'tie-break';\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"harmony.d.ts","sourceRoot":"","sources":["../src/harmony.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAGtC,eAAO,MAAM,eAAe,uGAOlB,CAAC;AAEX,MAAM,MAAM,YAAY,GAAG,CAAC,OAAO,eAAe,CAAC,CAAC,MAAM,CAAC,CAAC;AAE5D,MAAM,MAAM,kBAAkB,GAAG,SAAS,GAAG,WAAW,GAAG,UAAU,GAAG,YAAY,CAAC;AAErF,MAAM,WAAW,yBAAyB;IACxC,IAAI,EAAE,kBAAkB,CAAC;IACzB,GAAG,EAAE,QAAQ,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,UAAU,GAAG,WAAW,CAAC;CAClC;AAED,MAAM,WAAW,0BAA0B;IACzC,OAAO,EAAE,YAAY,CAAC;IACtB,MAAM,EAAE,SAAS,yBAAyB,EAAE,CAAC;IAC7C,OAAO,EAAE,yBAAyB,CAAC;IACnC,SAAS,CAAC,EAAE,yBAAyB,CAAC;IACtC,QAAQ,CAAC,EAAE,yBAAyB,CAAC;IACrC,UAAU,CAAC,EAAE,yBAAyB,CAAC;CACxC;AAoBD;;EAEE;AACF,wBAAgB,yBAAyB,CACvC,YAAY,EAAE,QAAQ,EACtB,OAAO,EAAE,YAAY,GACpB,0BAA0B,CAsC5B"}
1
+ {"version":3,"file":"harmony.d.ts","sourceRoot":"","sources":["../src/harmony.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAGtC,eAAO,MAAM,eAAe,uGAOlB,CAAC;AAEX,MAAM,MAAM,YAAY,GAAG,CAAC,OAAO,eAAe,CAAC,CAAC,MAAM,CAAC,CAAC;AAE5D,MAAM,MAAM,kBAAkB,GAAG,SAAS,GAAG,WAAW,GAAG,UAAU,GAAG,YAAY,CAAC;AAErF,MAAM,WAAW,yBAAyB;IACxC,IAAI,EAAE,kBAAkB,CAAC;IACzB,GAAG,EAAE,QAAQ,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,UAAU,GAAG,WAAW,CAAC;CAClC;AAED,MAAM,WAAW,0BAA0B;IACzC,OAAO,EAAE,YAAY,CAAC;IACtB,MAAM,EAAE,SAAS,yBAAyB,EAAE,CAAC;IAC7C,OAAO,EAAE,yBAAyB,CAAC;IACnC,SAAS,CAAC,EAAE,yBAAyB,CAAC;IACtC,QAAQ,CAAC,EAAE,yBAAyB,CAAC;IACrC,UAAU,CAAC,EAAE,yBAAyB,CAAC;CACxC;AAmCD;;EAEE;AACF,wBAAgB,yBAAyB,CACvC,YAAY,EAAE,QAAQ,EACtB,OAAO,EAAE,YAAY,GACpB,0BAA0B,CAqC5B"}
package/dist/harmony.js CHANGED
@@ -7,36 +7,46 @@ export const COLOR_HARMONIES = [
7
7
  'tetradic',
8
8
  'splitComplementary',
9
9
  ];
10
- const ROLE_ORDER_BY_HARMONY = {
11
- monochromatic: ['primary'],
12
- complementary: ['primary', 'secondary'],
13
- analogous: ['primary', 'secondary', 'tertiary'],
14
- splitComplementary: ['primary', 'secondary', 'tertiary'],
15
- triadic: ['primary', 'secondary', 'tertiary'],
16
- tetradic: ['primary', 'secondary', 'tertiary', 'quaternary'],
17
- };
18
- const OFFSETS_BY_HARMONY = {
19
- monochromatic: [0],
20
- analogous: [0, -30, 30],
21
- complementary: [0, 180],
22
- splitComplementary: [0, 150, 210],
23
- triadic: [0, 120, 240],
24
- tetradic: [0, 90, 180, 270],
25
- };
10
+ const ROLE_ORDER_BY_HARMONY = new Map([
11
+ ['monochromatic', ['primary']],
12
+ ['complementary', ['primary', 'secondary']],
13
+ ['analogous', ['primary', 'secondary', 'tertiary']],
14
+ ['splitComplementary', ['primary', 'secondary', 'tertiary']],
15
+ ['triadic', ['primary', 'secondary', 'tertiary']],
16
+ ['tetradic', ['primary', 'secondary', 'tertiary', 'quaternary']],
17
+ ]);
18
+ const OFFSETS_BY_HARMONY = new Map([
19
+ ['monochromatic', [0]],
20
+ ['analogous', [0, -30, 30]],
21
+ ['complementary', [0, 180]],
22
+ ['splitComplementary', [0, 150, 210]],
23
+ ['triadic', [0, 120, 240]],
24
+ ['tetradic', [0, 90, 180, 270]],
25
+ ]);
26
+ /***
27
+ Resolve a complete harmony definition from the canonical maps.
28
+ */
29
+ function getHarmonyDefinition(harmony) {
30
+ const roles = ROLE_ORDER_BY_HARMONY.get(harmony);
31
+ const offsets = OFFSETS_BY_HARMONY.get(harmony);
32
+ if (!roles || !offsets) {
33
+ throw new Error(`[color-theory] Missing definition for harmony ${harmony}.`);
34
+ }
35
+ return { offsets, roles };
36
+ }
26
37
  /***
27
38
  Generate role-based harmony colors from a primary color and harmony strategy.
28
39
  */
29
40
  export function generateHarmonyRoleColors(primaryColor, harmony) {
30
41
  const base = parseHexToOklch(primaryColor);
31
- const roles = ROLE_ORDER_BY_HARMONY[harmony];
32
- const offsets = OFFSETS_BY_HARMONY[harmony];
42
+ const { offsets, roles } = getHarmonyDefinition(harmony);
33
43
  const baseHue = normalizeHueDegrees(base.h);
34
44
  const colors = [];
35
45
  for (let index = 0; index < roles.length; index++) {
36
- const role = roles[index];
46
+ const role = roles.at(index);
37
47
  if (!role)
38
48
  continue;
39
- const hueDegrees = normalizeHueDegrees(baseHue + (offsets[index] ?? 0));
49
+ const hueDegrees = normalizeHueDegrees(baseHue + (offsets.at(index) ?? 0));
40
50
  colors.push({
41
51
  role,
42
52
  hex: role === 'primary' ? primaryColor : oklchToHex({ ...base, h: hueDegrees }),
@@ -1 +1 @@
1
- {"version":3,"file":"harmony.js","sourceRoot":"","sources":["../src/harmony.ts"],"names":[],"mappings":"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","sourcesContent":["import type { HexColor } from './hex';\nimport { normalizeHueDegrees, oklchToHex, parseHexToOklch } from './internal-culori';\n\nexport const COLOR_HARMONIES = [\n 'monochromatic',\n 'analogous',\n 'complementary',\n 'triadic',\n 'tetradic',\n 'splitComplementary',\n] as const;\n\nexport type ColorHarmony = (typeof COLOR_HARMONIES)[number];\n\nexport type GeneratedColorRole = 'primary' | 'secondary' | 'tertiary' | 'quaternary';\n\nexport interface GeneratedHarmonyRoleColor {\n role: GeneratedColorRole;\n hex: HexColor;\n hueDegrees: number;\n source: 'selected' | 'generated';\n}\n\nexport interface GeneratedHarmonyRoleColors {\n harmony: ColorHarmony;\n colors: readonly GeneratedHarmonyRoleColor[];\n primary: GeneratedHarmonyRoleColor;\n secondary?: GeneratedHarmonyRoleColor;\n tertiary?: GeneratedHarmonyRoleColor;\n quaternary?: GeneratedHarmonyRoleColor;\n}\n\nconst ROLE_ORDER_BY_HARMONY: Record<ColorHarmony, readonly GeneratedColorRole[]> = {\n monochromatic: ['primary'],\n complementary: ['primary', 'secondary'],\n analogous: ['primary', 'secondary', 'tertiary'],\n splitComplementary: ['primary', 'secondary', 'tertiary'],\n triadic: ['primary', 'secondary', 'tertiary'],\n tetradic: ['primary', 'secondary', 'tertiary', 'quaternary'],\n};\n\nconst OFFSETS_BY_HARMONY: Record<ColorHarmony, readonly number[]> = {\n monochromatic: [0],\n analogous: [0, -30, 30],\n complementary: [0, 180],\n splitComplementary: [0, 150, 210],\n triadic: [0, 120, 240],\n tetradic: [0, 90, 180, 270],\n};\n\n/***\n Generate role-based harmony colors from a primary color and harmony strategy.\n*/\nexport function generateHarmonyRoleColors(\n primaryColor: HexColor,\n harmony: ColorHarmony,\n): GeneratedHarmonyRoleColors {\n const base = parseHexToOklch(primaryColor);\n const roles = ROLE_ORDER_BY_HARMONY[harmony];\n const offsets = OFFSETS_BY_HARMONY[harmony];\n const baseHue = normalizeHueDegrees(base.h);\n\n const colors: GeneratedHarmonyRoleColor[] = [];\n\n for (let index = 0; index < roles.length; index++) {\n const role = roles[index];\n if (!role) continue;\n\n const hueDegrees = normalizeHueDegrees(baseHue + (offsets[index] ?? 0));\n colors.push({\n role,\n hex: role === 'primary' ? primaryColor : oklchToHex({ ...base, h: hueDegrees }),\n hueDegrees,\n source: role === 'primary' ? 'selected' : 'generated',\n });\n }\n\n const primary = colors.find((color) => color.role === 'primary');\n if (!primary) {\n throw new Error('[color-theory] Expected generated harmony role colors to include primary.');\n }\n\n const secondary = colors.find((color) => color.role === 'secondary');\n const tertiary = colors.find((color) => color.role === 'tertiary');\n const quaternary = colors.find((color) => color.role === 'quaternary');\n\n return {\n harmony,\n colors,\n primary,\n ...(secondary ? { secondary } : {}),\n ...(tertiary ? { tertiary } : {}),\n ...(quaternary ? { quaternary } : {}),\n };\n}\n"]}
1
+ {"version":3,"file":"harmony.js","sourceRoot":"","sources":["../src/harmony.ts"],"names":[],"mappings":"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","sourcesContent":["import type { HexColor } from './hex';\nimport { normalizeHueDegrees, oklchToHex, parseHexToOklch } from './internal-culori';\n\nexport const COLOR_HARMONIES = [\n 'monochromatic',\n 'analogous',\n 'complementary',\n 'triadic',\n 'tetradic',\n 'splitComplementary',\n] as const;\n\nexport type ColorHarmony = (typeof COLOR_HARMONIES)[number];\n\nexport type GeneratedColorRole = 'primary' | 'secondary' | 'tertiary' | 'quaternary';\n\nexport interface GeneratedHarmonyRoleColor {\n role: GeneratedColorRole;\n hex: HexColor;\n hueDegrees: number;\n source: 'selected' | 'generated';\n}\n\nexport interface GeneratedHarmonyRoleColors {\n harmony: ColorHarmony;\n colors: readonly GeneratedHarmonyRoleColor[];\n primary: GeneratedHarmonyRoleColor;\n secondary?: GeneratedHarmonyRoleColor;\n tertiary?: GeneratedHarmonyRoleColor;\n quaternary?: GeneratedHarmonyRoleColor;\n}\n\nconst ROLE_ORDER_BY_HARMONY = new Map<ColorHarmony, readonly GeneratedColorRole[]>([\n ['monochromatic', ['primary']],\n ['complementary', ['primary', 'secondary']],\n ['analogous', ['primary', 'secondary', 'tertiary']],\n ['splitComplementary', ['primary', 'secondary', 'tertiary']],\n ['triadic', ['primary', 'secondary', 'tertiary']],\n ['tetradic', ['primary', 'secondary', 'tertiary', 'quaternary']],\n]);\n\nconst OFFSETS_BY_HARMONY = new Map<ColorHarmony, readonly number[]>([\n ['monochromatic', [0]],\n ['analogous', [0, -30, 30]],\n ['complementary', [0, 180]],\n ['splitComplementary', [0, 150, 210]],\n ['triadic', [0, 120, 240]],\n ['tetradic', [0, 90, 180, 270]],\n]);\n\n/***\n Resolve a complete harmony definition from the canonical maps.\n*/\nfunction getHarmonyDefinition(harmony: ColorHarmony): {\n readonly offsets: readonly number[];\n readonly roles: readonly GeneratedColorRole[];\n} {\n const roles = ROLE_ORDER_BY_HARMONY.get(harmony);\n const offsets = OFFSETS_BY_HARMONY.get(harmony);\n if (!roles || !offsets) {\n throw new Error(`[color-theory] Missing definition for harmony ${harmony}.`);\n }\n return { offsets, roles };\n}\n\n/***\n Generate role-based harmony colors from a primary color and harmony strategy.\n*/\nexport function generateHarmonyRoleColors(\n primaryColor: HexColor,\n harmony: ColorHarmony,\n): GeneratedHarmonyRoleColors {\n const base = parseHexToOklch(primaryColor);\n const { offsets, roles } = getHarmonyDefinition(harmony);\n const baseHue = normalizeHueDegrees(base.h);\n\n const colors: GeneratedHarmonyRoleColor[] = [];\n\n for (let index = 0; index < roles.length; index++) {\n const role = roles.at(index);\n if (!role) continue;\n\n const hueDegrees = normalizeHueDegrees(baseHue + (offsets.at(index) ?? 0));\n colors.push({\n role,\n hex: role === 'primary' ? primaryColor : oklchToHex({ ...base, h: hueDegrees }),\n hueDegrees,\n source: role === 'primary' ? 'selected' : 'generated',\n });\n }\n\n const primary = colors.find((color) => color.role === 'primary');\n if (!primary) {\n throw new Error('[color-theory] Expected generated harmony role colors to include primary.');\n }\n\n const secondary = colors.find((color) => color.role === 'secondary');\n const tertiary = colors.find((color) => color.role === 'tertiary');\n const quaternary = colors.find((color) => color.role === 'quaternary');\n\n return {\n harmony,\n colors,\n primary,\n ...(secondary ? { secondary } : {}),\n ...(tertiary ? { tertiary } : {}),\n ...(quaternary ? { quaternary } : {}),\n };\n}\n"]}
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ export * from './contrast';
2
2
  export * from './harmony';
3
3
  export * from './hex';
4
4
  export * from './neutral';
5
+ export * from './selection';
5
6
  export * from './semantic-status';
6
7
  export * from './semantics';
7
8
  export * from './swatches';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,WAAW,CAAC;AAC1B,cAAc,OAAO,CAAC;AACtB,cAAc,WAAW,CAAC;AAC1B,cAAc,mBAAmB,CAAC;AAClC,cAAc,aAAa,CAAC;AAC5B,cAAc,YAAY,CAAC;AAC3B,cAAc,gBAAgB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,WAAW,CAAC;AAC1B,cAAc,OAAO,CAAC;AACtB,cAAc,WAAW,CAAC;AAC1B,cAAc,aAAa,CAAC;AAC5B,cAAc,mBAAmB,CAAC;AAClC,cAAc,aAAa,CAAC;AAC5B,cAAc,YAAY,CAAC;AAC3B,cAAc,gBAAgB,CAAC"}
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@ export * from './contrast';
2
2
  export * from './harmony';
3
3
  export * from './hex';
4
4
  export * from './neutral';
5
+ export * from './selection';
5
6
  export * from './semantic-status';
6
7
  export * from './semantics';
7
8
  export * from './swatches';
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,WAAW,CAAC;AAC1B,cAAc,OAAO,CAAC;AACtB,cAAc,WAAW,CAAC;AAC1B,cAAc,mBAAmB,CAAC;AAClC,cAAc,aAAa,CAAC;AAC5B,cAAc,YAAY,CAAC;AAC3B,cAAc,gBAAgB,CAAC","sourcesContent":["export * from './contrast';\nexport * from './harmony';\nexport * from './hex';\nexport * from './neutral';\nexport * from './semantic-status';\nexport * from './semantics';\nexport * from './swatches';\nexport * from './theme-colors';\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,WAAW,CAAC;AAC1B,cAAc,OAAO,CAAC;AACtB,cAAc,WAAW,CAAC;AAC1B,cAAc,aAAa,CAAC;AAC5B,cAAc,mBAAmB,CAAC;AAClC,cAAc,aAAa,CAAC;AAC5B,cAAc,YAAY,CAAC;AAC3B,cAAc,gBAAgB,CAAC","sourcesContent":["export * from './contrast';\nexport * from './harmony';\nexport * from './hex';\nexport * from './neutral';\nexport * from './selection';\nexport * from './semantic-status';\nexport * from './semantics';\nexport * from './swatches';\nexport * from './theme-colors';\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"neutral.d.ts","sourceRoot":"","sources":["../src/neutral.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAgB,0BAA0B,EAAE,MAAM,WAAW,CAAC;AAC1E,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAEtC,OAAO,EAAE,KAAK,WAAW,EAAE,KAAK,sBAAsB,EAAuB,MAAM,YAAY,CAAC;AAEhG,eAAO,MAAM,iBAAiB,QAAQ,CAAC;AAEvC,MAAM,WAAW,mBAAmB;IAClC,eAAe,EAAE,QAAQ,CAAC;IAC1B,OAAO,EAAE,WAAW,CAAC;IACrB,WAAW,EAAE,sBAAsB,CAAC;CACrC;AAED,MAAM,WAAW,wBAAwB;IACvC,eAAe,EAAE,QAAQ,CAAC;IAC1B,WAAW,EAAE,sBAAsB,CAAC;CACrC;AAkCD;;EAEE;AACF,wBAAgB,qBAAqB,CAAC,UAAU,EAAE,0BAA0B,GAAG,mBAAmB,CAuBjG"}
1
+ {"version":3,"file":"neutral.d.ts","sourceRoot":"","sources":["../src/neutral.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAgB,0BAA0B,EAAE,MAAM,WAAW,CAAC;AAC1E,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAEtC,OAAO,EAEL,KAAK,WAAW,EAChB,KAAK,sBAAsB,EAE5B,MAAM,YAAY,CAAC;AAEpB,eAAO,MAAM,iBAAiB,QAAQ,CAAC;AAEvC,MAAM,WAAW,mBAAmB;IAClC,eAAe,EAAE,QAAQ,CAAC;IAC1B,OAAO,EAAE,WAAW,CAAC;IACrB,WAAW,EAAE,sBAAsB,CAAC;CACrC;AAED,MAAM,WAAW,wBAAwB;IACvC,eAAe,EAAE,QAAQ,CAAC;IAC1B,WAAW,EAAE,sBAAsB,CAAC;CACrC;AAwBD;;EAEE;AACF,wBAAgB,qBAAqB,CAAC,UAAU,EAAE,0BAA0B,GAAG,mBAAmB,CAuBjG"}
package/dist/neutral.js CHANGED
@@ -1,22 +1,16 @@
1
1
  import { normalizeHueDegrees, oklchToHex, parseHexToOklch } from './internal-culori';
2
- import { generateColorSwatch } from './swatches';
2
+ import { COLOR_SWATCH_BASE_LIGHTNESS, generateColorSwatch, } from './swatches';
3
3
  export const MIN_HUEFUL_CHROMA = 0.015;
4
4
  /***
5
5
  Pick the harmony color that should tint the generated neutral swatch.
6
6
  */
7
7
  function pickTintSourceHex(roleColors, harmony) {
8
- const byMapping = {
9
- monochromatic: roleColors.primary.hex,
10
- complementary: roleColors.secondary?.hex,
11
- analogous: roleColors.tertiary?.hex,
12
- splitComplementary: roleColors.tertiary?.hex,
13
- triadic: roleColors.tertiary?.hex,
14
- tetradic: roleColors.tertiary?.hex,
15
- };
16
- return (byMapping[harmony] ??
17
- roleColors.tertiary?.hex ??
18
- roleColors.secondary?.hex ??
19
- roleColors.primary.hex);
8
+ if (harmony === 'monochromatic')
9
+ return roleColors.primary.hex;
10
+ if (harmony === 'complementary') {
11
+ return roleColors.secondary?.hex ?? roleColors.primary.hex;
12
+ }
13
+ return roleColors.tertiary?.hex ?? roleColors.secondary?.hex ?? roleColors.primary.hex;
20
14
  }
21
15
  /***
22
16
  Clamp a number to a finite minimum and maximum range.
@@ -40,7 +34,7 @@ export function generateNeutralSwatch(roleColors) {
40
34
  const neutralChroma = preferGray ? 0 : clampNumber(sourceChroma * 0.06, 0.004, 0.012);
41
35
  const neutralKeyColor = oklchToHex({
42
36
  mode: 'oklch',
43
- l: 0.6,
37
+ l: COLOR_SWATCH_BASE_LIGHTNESS,
44
38
  c: neutralChroma,
45
39
  h: neutralHue,
46
40
  });
@@ -1 +1 @@
1
- {"version":3,"file":"neutral.js","sourceRoot":"","sources":["../src/neutral.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,mBAAmB,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACrF,OAAO,EAAiD,mBAAmB,EAAE,MAAM,YAAY,CAAC;AAEhG,MAAM,CAAC,MAAM,iBAAiB,GAAG,KAAK,CAAC;AAavC;;EAEE;AACF,SAAS,iBAAiB,CACxB,UAAsC,EACtC,OAAqB;IAErB,MAAM,SAAS,GAAwD;QACrE,aAAa,EAAE,UAAU,CAAC,OAAO,CAAC,GAAG;QACrC,aAAa,EAAE,UAAU,CAAC,SAAS,EAAE,GAAG;QACxC,SAAS,EAAE,UAAU,CAAC,QAAQ,EAAE,GAAG;QACnC,kBAAkB,EAAE,UAAU,CAAC,QAAQ,EAAE,GAAG;QAC5C,OAAO,EAAE,UAAU,CAAC,QAAQ,EAAE,GAAG;QACjC,QAAQ,EAAE,UAAU,CAAC,QAAQ,EAAE,GAAG;KACnC,CAAC;IAEF,OAAO,CACL,SAAS,CAAC,OAAO,CAAC;QAClB,UAAU,CAAC,QAAQ,EAAE,GAAG;QACxB,UAAU,CAAC,SAAS,EAAE,GAAG;QACzB,UAAU,CAAC,OAAO,CAAC,GAAG,CACvB,CAAC;AACJ,CAAC;AAED;;EAEE;AACF,SAAS,WAAW,CAAC,KAAa,EAAE,GAAW,EAAE,GAAW;IAC1D,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC;QAAE,OAAO,GAAG,CAAC;IACxC,OAAO,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC,CAAC;AAC7C,CAAC;AAED;;EAEE;AACF,MAAM,UAAU,qBAAqB,CAAC,UAAsC;IAC1E,MAAM,aAAa,GAAG,iBAAiB,CAAC,UAAU,EAAE,UAAU,CAAC,OAAO,CAAC,CAAC;IACxE,MAAM,UAAU,GAAG,eAAe,CAAC,aAAa,CAAC,CAAC;IAElD,MAAM,OAAO,GAAG,eAAe,CAAC,UAAU,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;IAExD,MAAM,UAAU,GAAG,UAAU,CAAC,CAAC,GAAG,iBAAiB,CAAC;IAEpD,MAAM,GAAG,GAAG,UAAU,CAAC,OAAO,KAAK,eAAe,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC;IAC9E,MAAM,YAAY,GAAG,UAAU,CAAC,OAAO,KAAK,eAAe,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC;IACvF,MAAM,UAAU,GAAG,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,mBAAmB,CAAC,GAAG,CAAC,CAAC;IAC7D,MAAM,aAAa,GAAG,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC,YAAY,GAAG,IAAI,EAAE,KAAK,EAAE,KAAK,CAAC,CAAC;IAEtF,MAAM,eAAe,GAAG,UAAU,CAAC;QACjC,IAAI,EAAE,OAAO;QACb,CAAC,EAAE,GAAG;QACN,CAAC,EAAE,aAAa;QAChB,CAAC,EAAE,UAAU;KACd,CAAC,CAAC;IAEH,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,GAAG,mBAAmB,CAAC,eAAe,CAAC,CAAC;IAErE,OAAO,EAAE,eAAe,EAAE,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,CAAC;AAC3D,CAAC","sourcesContent":["import type { ColorHarmony, GeneratedHarmonyRoleColors } from './harmony';\nimport type { HexColor } from './hex';\nimport { normalizeHueDegrees, oklchToHex, parseHexToOklch } from './internal-culori';\nimport { type ColorSwatch, type ColorSwatchDiagnostics, generateColorSwatch } from './swatches';\n\nexport const MIN_HUEFUL_CHROMA = 0.015;\n\nexport interface NeutralSwatchResult {\n neutralKeyColor: HexColor;\n neutral: ColorSwatch;\n diagnostics: ColorSwatchDiagnostics;\n}\n\nexport interface GeneratedNeutralMetadata {\n neutralKeyColor: HexColor;\n diagnostics: ColorSwatchDiagnostics;\n}\n\n/***\n Pick the harmony color that should tint the generated neutral swatch.\n*/\nfunction pickTintSourceHex(\n roleColors: GeneratedHarmonyRoleColors,\n harmony: ColorHarmony,\n): HexColor {\n const byMapping: Partial<Record<ColorHarmony, HexColor | undefined>> = {\n monochromatic: roleColors.primary.hex,\n complementary: roleColors.secondary?.hex,\n analogous: roleColors.tertiary?.hex,\n splitComplementary: roleColors.tertiary?.hex,\n triadic: roleColors.tertiary?.hex,\n tetradic: roleColors.tertiary?.hex,\n };\n\n return (\n byMapping[harmony] ??\n roleColors.tertiary?.hex ??\n roleColors.secondary?.hex ??\n roleColors.primary.hex\n );\n}\n\n/***\n Clamp a number to a finite minimum and maximum range.\n*/\nfunction clampNumber(value: number, min: number, max: number): number {\n if (!Number.isFinite(value)) return min;\n return Math.min(max, Math.max(min, value));\n}\n\n/***\n Generate a softly tinted neutral swatch from generated harmony role colors.\n*/\nexport function generateNeutralSwatch(roleColors: GeneratedHarmonyRoleColors): NeutralSwatchResult {\n const tintSourceHex = pickTintSourceHex(roleColors, roleColors.harmony);\n const tintSource = parseHexToOklch(tintSourceHex);\n\n const primary = parseHexToOklch(roleColors.primary.hex);\n\n const preferGray = tintSource.c < MIN_HUEFUL_CHROMA;\n\n const hue = roleColors.harmony === 'monochromatic' ? primary.h : tintSource.h;\n const sourceChroma = roleColors.harmony === 'monochromatic' ? primary.c : tintSource.c;\n const neutralHue = preferGray ? 0 : normalizeHueDegrees(hue);\n const neutralChroma = preferGray ? 0 : clampNumber(sourceChroma * 0.06, 0.004, 0.012);\n\n const neutralKeyColor = oklchToHex({\n mode: 'oklch',\n l: 0.6,\n c: neutralChroma,\n h: neutralHue,\n });\n\n const { swatch, diagnostics } = generateColorSwatch(neutralKeyColor);\n\n return { neutralKeyColor, neutral: swatch, diagnostics };\n}\n"]}
1
+ {"version":3,"file":"neutral.js","sourceRoot":"","sources":["../src/neutral.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,mBAAmB,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACrF,OAAO,EACL,2BAA2B,EAG3B,mBAAmB,GACpB,MAAM,YAAY,CAAC;AAEpB,MAAM,CAAC,MAAM,iBAAiB,GAAG,KAAK,CAAC;AAavC;;EAEE;AACF,SAAS,iBAAiB,CACxB,UAAsC,EACtC,OAAqB;IAErB,IAAI,OAAO,KAAK,eAAe;QAAE,OAAO,UAAU,CAAC,OAAO,CAAC,GAAG,CAAC;IAC/D,IAAI,OAAO,KAAK,eAAe,EAAE,CAAC;QAChC,OAAO,UAAU,CAAC,SAAS,EAAE,GAAG,IAAI,UAAU,CAAC,OAAO,CAAC,GAAG,CAAC;IAC7D,CAAC;IACD,OAAO,UAAU,CAAC,QAAQ,EAAE,GAAG,IAAI,UAAU,CAAC,SAAS,EAAE,GAAG,IAAI,UAAU,CAAC,OAAO,CAAC,GAAG,CAAC;AACzF,CAAC;AAED;;EAEE;AACF,SAAS,WAAW,CAAC,KAAa,EAAE,GAAW,EAAE,GAAW;IAC1D,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC;QAAE,OAAO,GAAG,CAAC;IACxC,OAAO,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC,CAAC;AAC7C,CAAC;AAED;;EAEE;AACF,MAAM,UAAU,qBAAqB,CAAC,UAAsC;IAC1E,MAAM,aAAa,GAAG,iBAAiB,CAAC,UAAU,EAAE,UAAU,CAAC,OAAO,CAAC,CAAC;IACxE,MAAM,UAAU,GAAG,eAAe,CAAC,aAAa,CAAC,CAAC;IAElD,MAAM,OAAO,GAAG,eAAe,CAAC,UAAU,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;IAExD,MAAM,UAAU,GAAG,UAAU,CAAC,CAAC,GAAG,iBAAiB,CAAC;IAEpD,MAAM,GAAG,GAAG,UAAU,CAAC,OAAO,KAAK,eAAe,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC;IAC9E,MAAM,YAAY,GAAG,UAAU,CAAC,OAAO,KAAK,eAAe,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC;IACvF,MAAM,UAAU,GAAG,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,mBAAmB,CAAC,GAAG,CAAC,CAAC;IAC7D,MAAM,aAAa,GAAG,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC,YAAY,GAAG,IAAI,EAAE,KAAK,EAAE,KAAK,CAAC,CAAC;IAEtF,MAAM,eAAe,GAAG,UAAU,CAAC;QACjC,IAAI,EAAE,OAAO;QACb,CAAC,EAAE,2BAA2B;QAC9B,CAAC,EAAE,aAAa;QAChB,CAAC,EAAE,UAAU;KACd,CAAC,CAAC;IAEH,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,GAAG,mBAAmB,CAAC,eAAe,CAAC,CAAC;IAErE,OAAO,EAAE,eAAe,EAAE,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,CAAC;AAC3D,CAAC","sourcesContent":["import type { ColorHarmony, GeneratedHarmonyRoleColors } from './harmony';\nimport type { HexColor } from './hex';\nimport { normalizeHueDegrees, oklchToHex, parseHexToOklch } from './internal-culori';\nimport {\n COLOR_SWATCH_BASE_LIGHTNESS,\n type ColorSwatch,\n type ColorSwatchDiagnostics,\n generateColorSwatch,\n} from './swatches';\n\nexport const MIN_HUEFUL_CHROMA = 0.015;\n\nexport interface NeutralSwatchResult {\n neutralKeyColor: HexColor;\n neutral: ColorSwatch;\n diagnostics: ColorSwatchDiagnostics;\n}\n\nexport interface GeneratedNeutralMetadata {\n neutralKeyColor: HexColor;\n diagnostics: ColorSwatchDiagnostics;\n}\n\n/***\n Pick the harmony color that should tint the generated neutral swatch.\n*/\nfunction pickTintSourceHex(\n roleColors: GeneratedHarmonyRoleColors,\n harmony: ColorHarmony,\n): HexColor {\n if (harmony === 'monochromatic') return roleColors.primary.hex;\n if (harmony === 'complementary') {\n return roleColors.secondary?.hex ?? roleColors.primary.hex;\n }\n return roleColors.tertiary?.hex ?? roleColors.secondary?.hex ?? roleColors.primary.hex;\n}\n\n/***\n Clamp a number to a finite minimum and maximum range.\n*/\nfunction clampNumber(value: number, min: number, max: number): number {\n if (!Number.isFinite(value)) return min;\n return Math.min(max, Math.max(min, value));\n}\n\n/***\n Generate a softly tinted neutral swatch from generated harmony role colors.\n*/\nexport function generateNeutralSwatch(roleColors: GeneratedHarmonyRoleColors): NeutralSwatchResult {\n const tintSourceHex = pickTintSourceHex(roleColors, roleColors.harmony);\n const tintSource = parseHexToOklch(tintSourceHex);\n\n const primary = parseHexToOklch(roleColors.primary.hex);\n\n const preferGray = tintSource.c < MIN_HUEFUL_CHROMA;\n\n const hue = roleColors.harmony === 'monochromatic' ? primary.h : tintSource.h;\n const sourceChroma = roleColors.harmony === 'monochromatic' ? primary.c : tintSource.c;\n const neutralHue = preferGray ? 0 : normalizeHueDegrees(hue);\n const neutralChroma = preferGray ? 0 : clampNumber(sourceChroma * 0.06, 0.004, 0.012);\n\n const neutralKeyColor = oklchToHex({\n mode: 'oklch',\n l: COLOR_SWATCH_BASE_LIGHTNESS,\n c: neutralChroma,\n h: neutralHue,\n });\n\n const { swatch, diagnostics } = generateColorSwatch(neutralKeyColor);\n\n return { neutralKeyColor, neutral: swatch, diagnostics };\n}\n"]}
@@ -0,0 +1,42 @@
1
+ import type { HexColor } from './hex';
2
+ import { type ColorSwatch, type ColorSwatchStep } from './swatches';
3
+ export type ColorSwatchTiePolicy = 'lower-step' | 'higher-step';
4
+ export interface ColorSelectionTarget {
5
+ lightness: number;
6
+ chroma: number;
7
+ hueDegrees?: number;
8
+ }
9
+ export interface ColorContrastContext {
10
+ id: string;
11
+ against: HexColor;
12
+ minimumContrast: number;
13
+ }
14
+ export interface ColorContrastMeasurement extends ColorContrastContext {
15
+ contrast: number;
16
+ passes: boolean;
17
+ }
18
+ export type ColorSwatchCandidateRejectionReason = 'contrast' | 'target-distance' | 'tie-break';
19
+ export interface ColorSwatchSelectionCandidate {
20
+ step: ColorSwatchStep;
21
+ hex: HexColor;
22
+ targetDistance: number;
23
+ contrasts: readonly ColorContrastMeasurement[];
24
+ selected: boolean;
25
+ rejectionReasons: readonly ColorSwatchCandidateRejectionReason[];
26
+ }
27
+ export interface ColorSwatchSelectionResult {
28
+ selected: ColorSwatchSelectionCandidate | null;
29
+ candidates: readonly ColorSwatchSelectionCandidate[];
30
+ rejectedCandidates: readonly ColorSwatchSelectionCandidate[];
31
+ target: ColorSelectionTarget;
32
+ tieBreak: {
33
+ policy: ColorSwatchTiePolicy;
34
+ applied: boolean;
35
+ candidateSteps: readonly ColorSwatchStep[];
36
+ };
37
+ }
38
+ /***
39
+ Select the swatch step nearest a perceptual target that passes every required contrast context.
40
+ */
41
+ export declare function selectColorSwatchStep(swatch: ColorSwatch, target: ColorSelectionTarget, contexts: readonly ColorContrastContext[], tiePolicy: ColorSwatchTiePolicy): ColorSwatchSelectionResult;
42
+ //# sourceMappingURL=selection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"selection.d.ts","sourceRoot":"","sources":["../src/selection.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAGtC,OAAO,EAAsB,KAAK,WAAW,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AAExF,MAAM,MAAM,oBAAoB,GAAG,YAAY,GAAG,aAAa,CAAC;AAEhE,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,oBAAoB;IACnC,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,QAAQ,CAAC;IAClB,eAAe,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,wBAAyB,SAAQ,oBAAoB;IACpE,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,MAAM,mCAAmC,GAAG,UAAU,GAAG,iBAAiB,GAAG,WAAW,CAAC;AAE/F,MAAM,WAAW,6BAA6B;IAC5C,IAAI,EAAE,eAAe,CAAC;IACtB,GAAG,EAAE,QAAQ,CAAC;IACd,cAAc,EAAE,MAAM,CAAC;IACvB,SAAS,EAAE,SAAS,wBAAwB,EAAE,CAAC;IAC/C,QAAQ,EAAE,OAAO,CAAC;IAClB,gBAAgB,EAAE,SAAS,mCAAmC,EAAE,CAAC;CAClE;AAED,MAAM,WAAW,0BAA0B;IACzC,QAAQ,EAAE,6BAA6B,GAAG,IAAI,CAAC;IAC/C,UAAU,EAAE,SAAS,6BAA6B,EAAE,CAAC;IACrD,kBAAkB,EAAE,SAAS,6BAA6B,EAAE,CAAC;IAC7D,MAAM,EAAE,oBAAoB,CAAC;IAC7B,QAAQ,EAAE;QACR,MAAM,EAAE,oBAAoB,CAAC;QAC7B,OAAO,EAAE,OAAO,CAAC;QACjB,cAAc,EAAE,SAAS,eAAe,EAAE,CAAC;KAC5C,CAAC;CACH;AAYD;;EAEE;AACF,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,WAAW,EACnB,MAAM,EAAE,oBAAoB,EAC5B,QAAQ,EAAE,SAAS,oBAAoB,EAAE,EACzC,SAAS,EAAE,oBAAoB,GAC9B,0BAA0B,CA6B5B"}
@@ -0,0 +1,146 @@
1
+ import { getContrastRatio } from './contrast';
2
+ import { parseHexColorOrThrow } from './hex';
3
+ import { deltaEoklch, normalizeHueDegrees, parseHexToOklch } from './internal-culori';
4
+ import { COLOR_SWATCH_STEPS } from './swatches';
5
+ const DISTANCE_TIE_TOLERANCE = Number.EPSILON * 16;
6
+ /***
7
+ Select the swatch step nearest a perceptual target that passes every required contrast context.
8
+ */
9
+ export function selectColorSwatchStep(swatch, target, contexts, tiePolicy) {
10
+ const normalizedTarget = normalizeTarget(target);
11
+ const normalizedContexts = normalizeContexts(contexts);
12
+ const measured = COLOR_SWATCH_STEPS.map((step) => measureCandidate(swatch, step, normalizedTarget, normalizedContexts));
13
+ const eligible = measured.filter(({ passesContrast }) => passesContrast);
14
+ const nearestDistance = Math.min(...eligible.map(({ targetDistance }) => targetDistance));
15
+ const nearest = eligible.filter(({ targetDistance }) => Math.abs(targetDistance - nearestDistance) <= DISTANCE_TIE_TOLERANCE);
16
+ const selectedMeasured = tiePolicy === 'lower-step' ? nearest.at(0) : nearest.at(-1);
17
+ const tieCandidateSteps = nearest.map(({ step }) => step);
18
+ const candidates = measured.map((candidate) => createSelectionCandidate(candidate, selectedMeasured, nearestDistance, tieCandidateSteps));
19
+ const selected = candidates.find((candidate) => candidate.selected) ?? null;
20
+ return {
21
+ selected,
22
+ candidates,
23
+ rejectedCandidates: candidates.filter((candidate) => !candidate.selected),
24
+ target: normalizedTarget,
25
+ tieBreak: {
26
+ policy: tiePolicy,
27
+ applied: nearest.length > 1,
28
+ candidateSteps: tieCandidateSteps,
29
+ },
30
+ };
31
+ }
32
+ /***
33
+ Convert a measured candidate into its public selection diagnostic.
34
+ */
35
+ function createSelectionCandidate(candidate, selected, nearestDistance, tieCandidateSteps) {
36
+ const isSelected = selected?.step === candidate.step;
37
+ return {
38
+ step: candidate.step,
39
+ hex: candidate.hex,
40
+ targetDistance: candidate.targetDistance,
41
+ contrasts: candidate.contrasts,
42
+ selected: isSelected,
43
+ rejectionReasons: getCandidateRejectionReasons(candidate, isSelected, nearestDistance, tieCandidateSteps),
44
+ };
45
+ }
46
+ /***
47
+ Explain every reason a candidate was not selected.
48
+ */
49
+ function getCandidateRejectionReasons(candidate, selected, nearestDistance, tieCandidateSteps) {
50
+ if (selected)
51
+ return [];
52
+ if (!candidate.passesContrast)
53
+ return ['contrast'];
54
+ if (candidate.targetDistance > nearestDistance + DISTANCE_TIE_TOLERANCE) {
55
+ return ['target-distance'];
56
+ }
57
+ if (tieCandidateSteps.includes(candidate.step))
58
+ return ['tie-break'];
59
+ return ['target-distance'];
60
+ }
61
+ /***
62
+ Measure a swatch candidate against the target and required contrast contexts.
63
+ */
64
+ function measureCandidate(swatch, step, target, contexts) {
65
+ const value = Reflect.get(swatch, step);
66
+ if (typeof value !== 'string') {
67
+ throw new Error(`[color-theory] Missing color swatch step: ${step}.`);
68
+ }
69
+ const hex = parseHexColorOrThrow(value);
70
+ const contrasts = contexts.map((context) => {
71
+ const contrast = getContrastRatio(hex, context.against);
72
+ return { ...context, contrast, passes: contrast >= context.minimumContrast };
73
+ });
74
+ return {
75
+ step,
76
+ hex,
77
+ targetDistance: measureTargetDistance(hex, target),
78
+ contrasts,
79
+ passesContrast: contrasts.every(({ passes }) => passes),
80
+ };
81
+ }
82
+ /***
83
+ Measure target distance with hue included only when the caller supplied it.
84
+ */
85
+ function measureTargetDistance(hex, target) {
86
+ const candidate = parseHexToOklch(hex);
87
+ if (target.hueDegrees === undefined) {
88
+ return Math.hypot(candidate.l - target.lightness, candidate.c - target.chroma);
89
+ }
90
+ return deltaEoklch(candidate, {
91
+ mode: 'oklch',
92
+ l: target.lightness,
93
+ c: target.chroma,
94
+ h: target.hueDegrees,
95
+ });
96
+ }
97
+ /***
98
+ Validate and normalize contrast contexts for deterministic measurement.
99
+ */
100
+ function normalizeContexts(contexts) {
101
+ if (contexts.length === 0) {
102
+ throw new Error('[color-theory] Color contrast contexts must not be empty.');
103
+ }
104
+ const ids = new Set();
105
+ return contexts.map((context) => {
106
+ const id = context.id.trim();
107
+ if (!id)
108
+ throw new Error('[color-theory] Color contrast context id must not be empty.');
109
+ if (ids.has(id)) {
110
+ throw new Error(`[color-theory] Duplicate color contrast context id: ${JSON.stringify(id)}.`);
111
+ }
112
+ ids.add(id);
113
+ if (!Number.isFinite(context.minimumContrast) ||
114
+ context.minimumContrast < 1 ||
115
+ context.minimumContrast > 21) {
116
+ throw new Error(`[color-theory] Minimum contrast must be a finite ratio from 1 through 21: ${context.minimumContrast}.`);
117
+ }
118
+ return {
119
+ id,
120
+ against: parseHexColorOrThrow(context.against),
121
+ minimumContrast: context.minimumContrast,
122
+ };
123
+ });
124
+ }
125
+ /***
126
+ Validate and normalize a perceptual color-selection target.
127
+ */
128
+ function normalizeTarget(target) {
129
+ if (!Number.isFinite(target.lightness) || target.lightness < 0 || target.lightness > 1) {
130
+ throw new Error(`[color-theory] Target lightness must be finite and within 0 through 1: ${target.lightness}.`);
131
+ }
132
+ if (!Number.isFinite(target.chroma) || target.chroma < 0 || target.chroma > 0.4) {
133
+ throw new Error(`[color-theory] Target chroma must be finite and within 0 through 0.4: ${target.chroma}.`);
134
+ }
135
+ if (target.hueDegrees !== undefined && !Number.isFinite(target.hueDegrees)) {
136
+ throw new Error(`[color-theory] Target hue must be finite: ${target.hueDegrees}.`);
137
+ }
138
+ return {
139
+ lightness: target.lightness,
140
+ chroma: target.chroma,
141
+ ...(target.hueDegrees !== undefined
142
+ ? { hueDegrees: normalizeHueDegrees(target.hueDegrees) }
143
+ : {}),
144
+ };
145
+ }
146
+ //# sourceMappingURL=selection.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"selection.js","sourceRoot":"","sources":["../src/selection.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAE9C,OAAO,EAAE,oBAAoB,EAAE,MAAM,OAAO,CAAC;AAC7C,OAAO,EAAE,WAAW,EAAE,mBAAmB,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACtF,OAAO,EAAE,kBAAkB,EAA0C,MAAM,YAAY,CAAC;AAoDxF,MAAM,sBAAsB,GAAG,MAAM,CAAC,OAAO,GAAG,EAAE,CAAC;AAEnD;;EAEE;AACF,MAAM,UAAU,qBAAqB,CACnC,MAAmB,EACnB,MAA4B,EAC5B,QAAyC,EACzC,SAA+B;IAE/B,MAAM,gBAAgB,GAAG,eAAe,CAAC,MAAM,CAAC,CAAC;IACjD,MAAM,kBAAkB,GAAG,iBAAiB,CAAC,QAAQ,CAAC,CAAC;IACvD,MAAM,QAAQ,GAAG,kBAAkB,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAC/C,gBAAgB,CAAC,MAAM,EAAE,IAAI,EAAE,gBAAgB,EAAE,kBAAkB,CAAC,CACrE,CAAC;IACF,MAAM,QAAQ,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC,EAAE,cAAc,EAAE,EAAE,EAAE,CAAC,cAAc,CAAC,CAAC;IACzE,MAAM,eAAe,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,QAAQ,CAAC,GAAG,CAAC,CAAC,EAAE,cAAc,EAAE,EAAE,EAAE,CAAC,cAAc,CAAC,CAAC,CAAC;IAC1F,MAAM,OAAO,GAAG,QAAQ,CAAC,MAAM,CAC7B,CAAC,EAAE,cAAc,EAAE,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,cAAc,GAAG,eAAe,CAAC,IAAI,sBAAsB,CAC7F,CAAC;IACF,MAAM,gBAAgB,GAAG,SAAS,KAAK,YAAY,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;IACrF,MAAM,iBAAiB,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,IAAI,CAAC,CAAC;IAC1D,MAAM,UAAU,GAAG,QAAQ,CAAC,GAAG,CAAC,CAAC,SAAS,EAAE,EAAE,CAC5C,wBAAwB,CAAC,SAAS,EAAE,gBAAgB,EAAE,eAAe,EAAE,iBAAiB,CAAC,CAC1F,CAAC;IACF,MAAM,QAAQ,GAAG,UAAU,CAAC,IAAI,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,SAAS,CAAC,QAAQ,CAAC,IAAI,IAAI,CAAC;IAE5E,OAAO;QACL,QAAQ;QACR,UAAU;QACV,kBAAkB,EAAE,UAAU,CAAC,MAAM,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,CAAC,SAAS,CAAC,QAAQ,CAAC;QACzE,MAAM,EAAE,gBAAgB;QACxB,QAAQ,EAAE;YACR,MAAM,EAAE,SAAS;YACjB,OAAO,EAAE,OAAO,CAAC,MAAM,GAAG,CAAC;YAC3B,cAAc,EAAE,iBAAiB;SAClC;KACF,CAAC;AACJ,CAAC;AAED;;EAEE;AACF,SAAS,wBAAwB,CAC/B,SAAuC,EACvC,QAAkD,EAClD,eAAuB,EACvB,iBAA6C;IAE7C,MAAM,UAAU,GAAG,QAAQ,EAAE,IAAI,KAAK,SAAS,CAAC,IAAI,CAAC;IACrD,OAAO;QACL,IAAI,EAAE,SAAS,CAAC,IAAI;QACpB,GAAG,EAAE,SAAS,CAAC,GAAG;QAClB,cAAc,EAAE,SAAS,CAAC,cAAc;QACxC,SAAS,EAAE,SAAS,CAAC,SAAS;QAC9B,QAAQ,EAAE,UAAU;QACpB,gBAAgB,EAAE,4BAA4B,CAC5C,SAAS,EACT,UAAU,EACV,eAAe,EACf,iBAAiB,CAClB;KACF,CAAC;AACJ,CAAC;AAED;;EAEE;AACF,SAAS,4BAA4B,CACnC,SAAuC,EACvC,QAAiB,EACjB,eAAuB,EACvB,iBAA6C;IAE7C,IAAI,QAAQ;QAAE,OAAO,EAAE,CAAC;IACxB,IAAI,CAAC,SAAS,CAAC,cAAc;QAAE,OAAO,CAAC,UAAU,CAAC,CAAC;IACnD,IAAI,SAAS,CAAC,cAAc,GAAG,eAAe,GAAG,sBAAsB,EAAE,CAAC;QACxE,OAAO,CAAC,iBAAiB,CAAC,CAAC;IAC7B,CAAC;IACD,IAAI,iBAAiB,CAAC,QAAQ,CAAC,SAAS,CAAC,IAAI,CAAC;QAAE,OAAO,CAAC,WAAW,CAAC,CAAC;IACrE,OAAO,CAAC,iBAAiB,CAAC,CAAC;AAC7B,CAAC;AAED;;EAEE;AACF,SAAS,gBAAgB,CACvB,MAAmB,EACnB,IAAqB,EACrB,MAA4B,EAC5B,QAAyC;IAEzC,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;IACxC,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QAC9B,MAAM,IAAI,KAAK,CAAC,6CAA6C,IAAI,GAAG,CAAC,CAAC;IACxE,CAAC;IACD,MAAM,GAAG,GAAG,oBAAoB,CAAC,KAAK,CAAC,CAAC;IACxC,MAAM,SAAS,GAAG,QAAQ,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE;QACzC,MAAM,QAAQ,GAAG,gBAAgB,CAAC,GAAG,EAAE,OAAO,CAAC,OAAO,CAAC,CAAC;QACxD,OAAO,EAAE,GAAG,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,QAAQ,IAAI,OAAO,CAAC,eAAe,EAAE,CAAC;IAC/E,CAAC,CAAC,CAAC;IACH,OAAO;QACL,IAAI;QACJ,GAAG;QACH,cAAc,EAAE,qBAAqB,CAAC,GAAG,EAAE,MAAM,CAAC;QAClD,SAAS;QACT,cAAc,EAAE,SAAS,CAAC,KAAK,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC,MAAM,CAAC;KACxD,CAAC;AACJ,CAAC;AAED;;EAEE;AACF,SAAS,qBAAqB,CAAC,GAAa,EAAE,MAA4B;IACxE,MAAM,SAAS,GAAG,eAAe,CAAC,GAAG,CAAC,CAAC;IACvC,IAAI,MAAM,CAAC,UAAU,KAAK,SAAS,EAAE,CAAC;QACpC,OAAO,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,GAAG,MAAM,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC;IACjF,CAAC;IACD,OAAO,WAAW,CAAC,SAAS,EAAE;QAC5B,IAAI,EAAE,OAAO;QACb,CAAC,EAAE,MAAM,CAAC,SAAS;QACnB,CAAC,EAAE,MAAM,CAAC,MAAM;QAChB,CAAC,EAAE,MAAM,CAAC,UAAU;KACrB,CAAC,CAAC;AACL,CAAC;AAED;;EAEE;AACF,SAAS,iBAAiB,CACxB,QAAyC;IAEzC,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC1B,MAAM,IAAI,KAAK,CAAC,2DAA2D,CAAC,CAAC;IAC/E,CAAC;IACD,MAAM,GAAG,GAAG,IAAI,GAAG,EAAU,CAAC;IAC9B,OAAO,QAAQ,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE;QAC9B,MAAM,EAAE,GAAG,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,CAAC;QAC7B,IAAI,CAAC,EAAE;YAAE,MAAM,IAAI,KAAK,CAAC,6DAA6D,CAAC,CAAC;QACxF,IAAI,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,CAAC;YAChB,MAAM,IAAI,KAAK,CAAC,uDAAuD,IAAI,CAAC,SAAS,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC;QAChG,CAAC;QACD,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QACZ,IACE,CAAC,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC,eAAe,CAAC;YACzC,OAAO,CAAC,eAAe,GAAG,CAAC;YAC3B,OAAO,CAAC,eAAe,GAAG,EAAE,EAC5B,CAAC;YACD,MAAM,IAAI,KAAK,CACb,6EAA6E,OAAO,CAAC,eAAe,GAAG,CACxG,CAAC;QACJ,CAAC;QACD,OAAO;YACL,EAAE;YACF,OAAO,EAAE,oBAAoB,CAAC,OAAO,CAAC,OAAO,CAAC;YAC9C,eAAe,EAAE,OAAO,CAAC,eAAe;SACzC,CAAC;IACJ,CAAC,CAAC,CAAC;AACL,CAAC;AAED;;EAEE;AACF,SAAS,eAAe,CAAC,MAA4B;IACnD,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,SAAS,CAAC,IAAI,MAAM,CAAC,SAAS,GAAG,CAAC,IAAI,MAAM,CAAC,SAAS,GAAG,CAAC,EAAE,CAAC;QACvF,MAAM,IAAI,KAAK,CACb,0EAA0E,MAAM,CAAC,SAAS,GAAG,CAC9F,CAAC;IACJ,CAAC;IACD,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC,IAAI,MAAM,CAAC,MAAM,GAAG,GAAG,EAAE,CAAC;QAChF,MAAM,IAAI,KAAK,CACb,yEAAyE,MAAM,CAAC,MAAM,GAAG,CAC1F,CAAC;IACJ,CAAC;IACD,IAAI,MAAM,CAAC,UAAU,KAAK,SAAS,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,UAAU,CAAC,EAAE,CAAC;QAC3E,MAAM,IAAI,KAAK,CAAC,6CAA6C,MAAM,CAAC,UAAU,GAAG,CAAC,CAAC;IACrF,CAAC;IACD,OAAO;QACL,SAAS,EAAE,MAAM,CAAC,SAAS;QAC3B,MAAM,EAAE,MAAM,CAAC,MAAM;QACrB,GAAG,CAAC,MAAM,CAAC,UAAU,KAAK,SAAS;YACjC,CAAC,CAAC,EAAE,UAAU,EAAE,mBAAmB,CAAC,MAAM,CAAC,UAAU,CAAC,EAAE;YACxD,CAAC,CAAC,EAAE,CAAC;KACR,CAAC;AACJ,CAAC","sourcesContent":["import { getContrastRatio } from './contrast';\nimport type { HexColor } from './hex';\nimport { parseHexColorOrThrow } from './hex';\nimport { deltaEoklch, normalizeHueDegrees, parseHexToOklch } from './internal-culori';\nimport { COLOR_SWATCH_STEPS, type ColorSwatch, type ColorSwatchStep } from './swatches';\n\nexport type ColorSwatchTiePolicy = 'lower-step' | 'higher-step';\n\nexport interface ColorSelectionTarget {\n lightness: number;\n chroma: number;\n hueDegrees?: number;\n}\n\nexport interface ColorContrastContext {\n id: string;\n against: HexColor;\n minimumContrast: number;\n}\n\nexport interface ColorContrastMeasurement extends ColorContrastContext {\n contrast: number;\n passes: boolean;\n}\n\nexport type ColorSwatchCandidateRejectionReason = 'contrast' | 'target-distance' | 'tie-break';\n\nexport interface ColorSwatchSelectionCandidate {\n step: ColorSwatchStep;\n hex: HexColor;\n targetDistance: number;\n contrasts: readonly ColorContrastMeasurement[];\n selected: boolean;\n rejectionReasons: readonly ColorSwatchCandidateRejectionReason[];\n}\n\nexport interface ColorSwatchSelectionResult {\n selected: ColorSwatchSelectionCandidate | null;\n candidates: readonly ColorSwatchSelectionCandidate[];\n rejectedCandidates: readonly ColorSwatchSelectionCandidate[];\n target: ColorSelectionTarget;\n tieBreak: {\n policy: ColorSwatchTiePolicy;\n applied: boolean;\n candidateSteps: readonly ColorSwatchStep[];\n };\n}\n\ninterface MeasuredColorSwatchCandidate {\n readonly step: ColorSwatchStep;\n readonly hex: HexColor;\n readonly targetDistance: number;\n readonly contrasts: readonly ColorContrastMeasurement[];\n readonly passesContrast: boolean;\n}\n\nconst DISTANCE_TIE_TOLERANCE = Number.EPSILON * 16;\n\n/***\n Select the swatch step nearest a perceptual target that passes every required contrast context.\n*/\nexport function selectColorSwatchStep(\n swatch: ColorSwatch,\n target: ColorSelectionTarget,\n contexts: readonly ColorContrastContext[],\n tiePolicy: ColorSwatchTiePolicy,\n): ColorSwatchSelectionResult {\n const normalizedTarget = normalizeTarget(target);\n const normalizedContexts = normalizeContexts(contexts);\n const measured = COLOR_SWATCH_STEPS.map((step) =>\n measureCandidate(swatch, step, normalizedTarget, normalizedContexts),\n );\n const eligible = measured.filter(({ passesContrast }) => passesContrast);\n const nearestDistance = Math.min(...eligible.map(({ targetDistance }) => targetDistance));\n const nearest = eligible.filter(\n ({ targetDistance }) => Math.abs(targetDistance - nearestDistance) <= DISTANCE_TIE_TOLERANCE,\n );\n const selectedMeasured = tiePolicy === 'lower-step' ? nearest.at(0) : nearest.at(-1);\n const tieCandidateSteps = nearest.map(({ step }) => step);\n const candidates = measured.map((candidate) =>\n createSelectionCandidate(candidate, selectedMeasured, nearestDistance, tieCandidateSteps),\n );\n const selected = candidates.find((candidate) => candidate.selected) ?? null;\n\n return {\n selected,\n candidates,\n rejectedCandidates: candidates.filter((candidate) => !candidate.selected),\n target: normalizedTarget,\n tieBreak: {\n policy: tiePolicy,\n applied: nearest.length > 1,\n candidateSteps: tieCandidateSteps,\n },\n };\n}\n\n/***\n Convert a measured candidate into its public selection diagnostic.\n*/\nfunction createSelectionCandidate(\n candidate: MeasuredColorSwatchCandidate,\n selected: MeasuredColorSwatchCandidate | undefined,\n nearestDistance: number,\n tieCandidateSteps: readonly ColorSwatchStep[],\n): ColorSwatchSelectionCandidate {\n const isSelected = selected?.step === candidate.step;\n return {\n step: candidate.step,\n hex: candidate.hex,\n targetDistance: candidate.targetDistance,\n contrasts: candidate.contrasts,\n selected: isSelected,\n rejectionReasons: getCandidateRejectionReasons(\n candidate,\n isSelected,\n nearestDistance,\n tieCandidateSteps,\n ),\n };\n}\n\n/***\n Explain every reason a candidate was not selected.\n*/\nfunction getCandidateRejectionReasons(\n candidate: MeasuredColorSwatchCandidate,\n selected: boolean,\n nearestDistance: number,\n tieCandidateSteps: readonly ColorSwatchStep[],\n): readonly ColorSwatchCandidateRejectionReason[] {\n if (selected) return [];\n if (!candidate.passesContrast) return ['contrast'];\n if (candidate.targetDistance > nearestDistance + DISTANCE_TIE_TOLERANCE) {\n return ['target-distance'];\n }\n if (tieCandidateSteps.includes(candidate.step)) return ['tie-break'];\n return ['target-distance'];\n}\n\n/***\n Measure a swatch candidate against the target and required contrast contexts.\n*/\nfunction measureCandidate(\n swatch: ColorSwatch,\n step: ColorSwatchStep,\n target: ColorSelectionTarget,\n contexts: readonly ColorContrastContext[],\n): MeasuredColorSwatchCandidate {\n const value = Reflect.get(swatch, step);\n if (typeof value !== 'string') {\n throw new Error(`[color-theory] Missing color swatch step: ${step}.`);\n }\n const hex = parseHexColorOrThrow(value);\n const contrasts = contexts.map((context) => {\n const contrast = getContrastRatio(hex, context.against);\n return { ...context, contrast, passes: contrast >= context.minimumContrast };\n });\n return {\n step,\n hex,\n targetDistance: measureTargetDistance(hex, target),\n contrasts,\n passesContrast: contrasts.every(({ passes }) => passes),\n };\n}\n\n/***\n Measure target distance with hue included only when the caller supplied it.\n*/\nfunction measureTargetDistance(hex: HexColor, target: ColorSelectionTarget): number {\n const candidate = parseHexToOklch(hex);\n if (target.hueDegrees === undefined) {\n return Math.hypot(candidate.l - target.lightness, candidate.c - target.chroma);\n }\n return deltaEoklch(candidate, {\n mode: 'oklch',\n l: target.lightness,\n c: target.chroma,\n h: target.hueDegrees,\n });\n}\n\n/***\n Validate and normalize contrast contexts for deterministic measurement.\n*/\nfunction normalizeContexts(\n contexts: readonly ColorContrastContext[],\n): readonly ColorContrastContext[] {\n if (contexts.length === 0) {\n throw new Error('[color-theory] Color contrast contexts must not be empty.');\n }\n const ids = new Set<string>();\n return contexts.map((context) => {\n const id = context.id.trim();\n if (!id) throw new Error('[color-theory] Color contrast context id must not be empty.');\n if (ids.has(id)) {\n throw new Error(`[color-theory] Duplicate color contrast context id: ${JSON.stringify(id)}.`);\n }\n ids.add(id);\n if (\n !Number.isFinite(context.minimumContrast) ||\n context.minimumContrast < 1 ||\n context.minimumContrast > 21\n ) {\n throw new Error(\n `[color-theory] Minimum contrast must be a finite ratio from 1 through 21: ${context.minimumContrast}.`,\n );\n }\n return {\n id,\n against: parseHexColorOrThrow(context.against),\n minimumContrast: context.minimumContrast,\n };\n });\n}\n\n/***\n Validate and normalize a perceptual color-selection target.\n*/\nfunction normalizeTarget(target: ColorSelectionTarget): ColorSelectionTarget {\n if (!Number.isFinite(target.lightness) || target.lightness < 0 || target.lightness > 1) {\n throw new Error(\n `[color-theory] Target lightness must be finite and within 0 through 1: ${target.lightness}.`,\n );\n }\n if (!Number.isFinite(target.chroma) || target.chroma < 0 || target.chroma > 0.4) {\n throw new Error(\n `[color-theory] Target chroma must be finite and within 0 through 0.4: ${target.chroma}.`,\n );\n }\n if (target.hueDegrees !== undefined && !Number.isFinite(target.hueDegrees)) {\n throw new Error(`[color-theory] Target hue must be finite: ${target.hueDegrees}.`);\n }\n return {\n lightness: target.lightness,\n chroma: target.chroma,\n ...(target.hueDegrees !== undefined\n ? { hueDegrees: normalizeHueDegrees(target.hueDegrees) }\n : {}),\n };\n}\n"]}
@@ -12,6 +12,12 @@ export interface SemanticStatusSwatches<Role extends string> {
12
12
  swatches: Record<Role, ColorSwatch>;
13
13
  diagnostics: Record<Role, ColorSwatchDiagnostics>;
14
14
  }
15
+ /***
16
+ Generate semantic status swatches from a role-to-seed record.
17
+ */
15
18
  export declare function createSemanticStatusSwatches<Role extends string>(seeds: Record<Role, SemanticStatusSeedInput>): SemanticStatusSwatches<Role>;
19
+ /***
20
+ Generate the canonical default semantic status swatches.
21
+ */
16
22
  export declare function createDefaultSemanticStatusSwatches(): SemanticStatusSwatches<DefaultSemanticStatusRole>;
17
23
  //# sourceMappingURL=semantic-status.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"semantic-status.d.ts","sourceRoot":"","sources":["../src/semantic-status.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAEtC,OAAO,KAAK,EAAE,WAAW,EAAE,sBAAsB,EAAE,MAAM,YAAY,CAAC;AAGtE,eAAO,MAAM,mCAAmC;;;;CAIL,CAAC;AAE5C,MAAM,MAAM,yBAAyB,GAAG,MAAM,OAAO,mCAAmC,CAAC;AAEzF,MAAM,MAAM,uBAAuB,GAAG,MAAM,GAAG,QAAQ,CAAC;AAExD,MAAM,WAAW,sBAAsB,CAAC,IAAI,SAAS,MAAM;IACzD,KAAK,EAAE,MAAM,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;IAC9B,QAAQ,EAAE,MAAM,CAAC,IAAI,EAAE,WAAW,CAAC,CAAC;IACpC,WAAW,EAAE,MAAM,CAAC,IAAI,EAAE,sBAAsB,CAAC,CAAC;CACnD;AAED,wBAAgB,4BAA4B,CAAC,IAAI,SAAS,MAAM,EAC9D,KAAK,EAAE,MAAM,CAAC,IAAI,EAAE,uBAAuB,CAAC,GAC3C,sBAAsB,CAAC,IAAI,CAAC,CAkB9B;AAED,wBAAgB,mCAAmC,IAAI,sBAAsB,CAAC,yBAAyB,CAAC,CAEvG"}
1
+ {"version":3,"file":"semantic-status.d.ts","sourceRoot":"","sources":["../src/semantic-status.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAEtC,OAAO,KAAK,EAAE,WAAW,EAAE,sBAAsB,EAAE,MAAM,YAAY,CAAC;AAGtE,eAAO,MAAM,mCAAmC;;;;CAIL,CAAC;AAE5C,MAAM,MAAM,yBAAyB,GAAG,MAAM,OAAO,mCAAmC,CAAC;AAEzF,MAAM,MAAM,uBAAuB,GAAG,MAAM,GAAG,QAAQ,CAAC;AAExD,MAAM,WAAW,sBAAsB,CAAC,IAAI,SAAS,MAAM;IACzD,KAAK,EAAE,MAAM,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;IAC9B,QAAQ,EAAE,MAAM,CAAC,IAAI,EAAE,WAAW,CAAC,CAAC;IACpC,WAAW,EAAE,MAAM,CAAC,IAAI,EAAE,sBAAsB,CAAC,CAAC;CACnD;AAmCD;;EAEE;AACF,wBAAgB,4BAA4B,CAAC,IAAI,SAAS,MAAM,EAC9D,KAAK,EAAE,MAAM,CAAC,IAAI,EAAE,uBAAuB,CAAC,GAC3C,sBAAsB,CAAC,IAAI,CAAC,CAS9B;AAED;;EAEE;AACF,wBAAgB,mCAAmC,IAAI,sBAAsB,CAAC,yBAAyB,CAAC,CAEvG"}
@@ -5,21 +5,39 @@ export const DEFAULT_SEMANTIC_STATUS_COLOR_SEEDS = {
5
5
  success: '#22c55e',
6
6
  warning: '#f59e0b',
7
7
  };
8
+ /***
9
+ Convert typed entries into a role-keyed immutable result record.
10
+ */
11
+ function createRoleRecord(entries) {
12
+ return Object.fromEntries(entries);
13
+ }
14
+ /***
15
+ Normalize and generate one semantic status role.
16
+ */
17
+ function generateSemanticStatus(role, seed) {
18
+ const seedHex = typeof seed === 'string' ? parseHexColorOrThrow(seed) : seed;
19
+ const generated = generateColorSwatch(seedHex);
20
+ return {
21
+ role,
22
+ seed: seedHex,
23
+ swatch: generated.swatch,
24
+ diagnostics: generated.diagnostics,
25
+ };
26
+ }
27
+ /***
28
+ Generate semantic status swatches from a role-to-seed record.
29
+ */
8
30
  export function createSemanticStatusSwatches(seeds) {
9
- const roles = Object.keys(seeds);
10
- const normalizedSeeds = {};
11
- const swatches = {};
12
- const diagnostics = {};
13
- for (const role of roles) {
14
- const seed = seeds[role];
15
- const seedHex = typeof seed === 'string' ? parseHexColorOrThrow(seed) : seed;
16
- normalizedSeeds[role] = seedHex;
17
- const generated = generateColorSwatch(seedHex);
18
- swatches[role] = generated.swatch;
19
- diagnostics[role] = generated.diagnostics;
20
- }
21
- return { seeds: normalizedSeeds, swatches, diagnostics };
31
+ const generated = Object.entries(seeds).map(([role, seed]) => generateSemanticStatus(role, seed));
32
+ return {
33
+ seeds: createRoleRecord(generated.map(({ role, seed }) => [role, seed])),
34
+ swatches: createRoleRecord(generated.map(({ role, swatch }) => [role, swatch])),
35
+ diagnostics: createRoleRecord(generated.map(({ diagnostics, role }) => [role, diagnostics])),
36
+ };
22
37
  }
38
+ /***
39
+ Generate the canonical default semantic status swatches.
40
+ */
23
41
  export function createDefaultSemanticStatusSwatches() {
24
42
  return createSemanticStatusSwatches(DEFAULT_SEMANTIC_STATUS_COLOR_SEEDS);
25
43
  }
@@ -1 +1 @@
1
- {"version":3,"file":"semantic-status.js","sourceRoot":"","sources":["../src/semantic-status.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,oBAAoB,EAAE,MAAM,OAAO,CAAC;AAE7C,OAAO,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AAEjD,MAAM,CAAC,MAAM,mCAAmC,GAAG;IACjD,MAAM,EAAE,SAAS;IACjB,OAAO,EAAE,SAAS;IAClB,OAAO,EAAE,SAAS;CACuB,CAAC;AAY5C,MAAM,UAAU,4BAA4B,CAC1C,KAA4C;IAE5C,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK,CAAW,CAAC;IAE3C,MAAM,eAAe,GAAG,EAA4B,CAAC;IACrD,MAAM,QAAQ,GAAG,EAA+B,CAAC;IACjD,MAAM,WAAW,GAAG,EAA0C,CAAC;IAE/D,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC,CAAC;QACzB,MAAM,OAAO,GAAG,OAAO,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,oBAAoB,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;QAC7E,eAAe,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC;QAEhC,MAAM,SAAS,GAAG,mBAAmB,CAAC,OAAO,CAAC,CAAC;QAC/C,QAAQ,CAAC,IAAI,CAAC,GAAG,SAAS,CAAC,MAAM,CAAC;QAClC,WAAW,CAAC,IAAI,CAAC,GAAG,SAAS,CAAC,WAAW,CAAC;IAC5C,CAAC;IAED,OAAO,EAAE,KAAK,EAAE,eAAe,EAAE,QAAQ,EAAE,WAAW,EAAE,CAAC;AAC3D,CAAC;AAED,MAAM,UAAU,mCAAmC;IACjD,OAAO,4BAA4B,CAAC,mCAAmC,CAAC,CAAC;AAC3E,CAAC","sourcesContent":["import type { HexColor } from './hex';\nimport { parseHexColorOrThrow } from './hex';\nimport type { ColorSwatch, ColorSwatchDiagnostics } from './swatches';\nimport { generateColorSwatch } from './swatches';\n\nexport const DEFAULT_SEMANTIC_STATUS_COLOR_SEEDS = {\n danger: '#ef4444',\n success: '#22c55e',\n warning: '#f59e0b',\n} as const satisfies Record<string, string>;\n\nexport type DefaultSemanticStatusRole = keyof typeof DEFAULT_SEMANTIC_STATUS_COLOR_SEEDS;\n\nexport type SemanticStatusSeedInput = string | HexColor;\n\nexport interface SemanticStatusSwatches<Role extends string> {\n seeds: Record<Role, HexColor>;\n swatches: Record<Role, ColorSwatch>;\n diagnostics: Record<Role, ColorSwatchDiagnostics>;\n}\n\nexport function createSemanticStatusSwatches<Role extends string>(\n seeds: Record<Role, SemanticStatusSeedInput>,\n): SemanticStatusSwatches<Role> {\n const roles = Object.keys(seeds) as Role[];\n\n const normalizedSeeds = {} as Record<Role, HexColor>;\n const swatches = {} as Record<Role, ColorSwatch>;\n const diagnostics = {} as Record<Role, ColorSwatchDiagnostics>;\n\n for (const role of roles) {\n const seed = seeds[role];\n const seedHex = typeof seed === 'string' ? parseHexColorOrThrow(seed) : seed;\n normalizedSeeds[role] = seedHex;\n\n const generated = generateColorSwatch(seedHex);\n swatches[role] = generated.swatch;\n diagnostics[role] = generated.diagnostics;\n }\n\n return { seeds: normalizedSeeds, swatches, diagnostics };\n}\n\nexport function createDefaultSemanticStatusSwatches(): SemanticStatusSwatches<DefaultSemanticStatusRole> {\n return createSemanticStatusSwatches(DEFAULT_SEMANTIC_STATUS_COLOR_SEEDS);\n}\n"]}
1
+ {"version":3,"file":"semantic-status.js","sourceRoot":"","sources":["../src/semantic-status.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,oBAAoB,EAAE,MAAM,OAAO,CAAC;AAE7C,OAAO,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AAEjD,MAAM,CAAC,MAAM,mCAAmC,GAAG;IACjD,MAAM,EAAE,SAAS;IACjB,OAAO,EAAE,SAAS;IAClB,OAAO,EAAE,SAAS;CACuB,CAAC;AAmB5C;;EAEE;AACF,SAAS,gBAAgB,CACvB,OAA4C;IAE5C,OAAO,MAAM,CAAC,WAAW,CAAC,OAAO,CAAwB,CAAC;AAC5D,CAAC;AAED;;EAEE;AACF,SAAS,sBAAsB,CAC7B,IAAU,EACV,IAA6B;IAE7B,MAAM,OAAO,GAAG,OAAO,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,oBAAoB,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IAC7E,MAAM,SAAS,GAAG,mBAAmB,CAAC,OAAO,CAAC,CAAC;IAC/C,OAAO;QACL,IAAI;QACJ,IAAI,EAAE,OAAO;QACb,MAAM,EAAE,SAAS,CAAC,MAAM;QACxB,WAAW,EAAE,SAAS,CAAC,WAAW;KACnC,CAAC;AACJ,CAAC;AAED;;EAEE;AACF,MAAM,UAAU,4BAA4B,CAC1C,KAA4C;IAE5C,MAAM,SAAS,GAAG,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,IAAI,CAAC,EAAE,EAAE,CAC3D,sBAAsB,CAAC,IAAY,EAAE,IAA+B,CAAC,CACtE,CAAC;IACF,OAAO;QACL,KAAK,EAAE,gBAAgB,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC;QACxE,QAAQ,EAAE,gBAAgB,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC;QAC/E,WAAW,EAAE,gBAAgB,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,EAAE,WAAW,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,WAAW,CAAC,CAAC,CAAC;KAC7F,CAAC;AACJ,CAAC;AAED;;EAEE;AACF,MAAM,UAAU,mCAAmC;IACjD,OAAO,4BAA4B,CAAC,mCAAmC,CAAC,CAAC;AAC3E,CAAC","sourcesContent":["import type { HexColor } from './hex';\nimport { parseHexColorOrThrow } from './hex';\nimport type { ColorSwatch, ColorSwatchDiagnostics } from './swatches';\nimport { generateColorSwatch } from './swatches';\n\nexport const DEFAULT_SEMANTIC_STATUS_COLOR_SEEDS = {\n danger: '#ef4444',\n success: '#22c55e',\n warning: '#f59e0b',\n} as const satisfies Record<string, string>;\n\nexport type DefaultSemanticStatusRole = keyof typeof DEFAULT_SEMANTIC_STATUS_COLOR_SEEDS;\n\nexport type SemanticStatusSeedInput = string | HexColor;\n\nexport interface SemanticStatusSwatches<Role extends string> {\n seeds: Record<Role, HexColor>;\n swatches: Record<Role, ColorSwatch>;\n diagnostics: Record<Role, ColorSwatchDiagnostics>;\n}\n\ninterface GeneratedSemanticStatus<Role extends string> {\n readonly diagnostics: ColorSwatchDiagnostics;\n readonly role: Role;\n readonly seed: HexColor;\n readonly swatch: ColorSwatch;\n}\n\n/***\n Convert typed entries into a role-keyed immutable result record.\n*/\nfunction createRoleRecord<Role extends string, Value>(\n entries: readonly (readonly [Role, Value])[],\n): Record<Role, Value> {\n return Object.fromEntries(entries) as Record<Role, Value>;\n}\n\n/***\n Normalize and generate one semantic status role.\n*/\nfunction generateSemanticStatus<Role extends string>(\n role: Role,\n seed: SemanticStatusSeedInput,\n): GeneratedSemanticStatus<Role> {\n const seedHex = typeof seed === 'string' ? parseHexColorOrThrow(seed) : seed;\n const generated = generateColorSwatch(seedHex);\n return {\n role,\n seed: seedHex,\n swatch: generated.swatch,\n diagnostics: generated.diagnostics,\n };\n}\n\n/***\n Generate semantic status swatches from a role-to-seed record.\n*/\nexport function createSemanticStatusSwatches<Role extends string>(\n seeds: Record<Role, SemanticStatusSeedInput>,\n): SemanticStatusSwatches<Role> {\n const generated = Object.entries(seeds).map(([role, seed]) =>\n generateSemanticStatus(role as Role, seed as SemanticStatusSeedInput),\n );\n return {\n seeds: createRoleRecord(generated.map(({ role, seed }) => [role, seed])),\n swatches: createRoleRecord(generated.map(({ role, swatch }) => [role, swatch])),\n diagnostics: createRoleRecord(generated.map(({ diagnostics, role }) => [role, diagnostics])),\n };\n}\n\n/***\n Generate the canonical default semantic status swatches.\n*/\nexport function createDefaultSemanticStatusSwatches(): SemanticStatusSwatches<DefaultSemanticStatusRole> {\n return createSemanticStatusSwatches(DEFAULT_SEMANTIC_STATUS_COLOR_SEEDS);\n}\n"]}
@@ -2,6 +2,7 @@ import type { HexColor } from './hex';
2
2
  export declare const COLOR_SWATCH_STEPS: readonly [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
3
3
  export type ColorSwatchStep = (typeof COLOR_SWATCH_STEPS)[number];
4
4
  export declare const COLOR_SWATCH_BASE_STEP: 500;
5
+ export declare const COLOR_SWATCH_BASE_LIGHTNESS = 0.551;
5
6
  export type ColorSwatch = Record<ColorSwatchStep, HexColor>;
6
7
  export type ColorSwatchWarningCode = 'weak_step' | 'weak_adjacent_delta' | 'limited_lightness_range';
7
8
  export interface ColorSwatchWarning {
@@ -1 +1 @@
1
- {"version":3,"file":"swatches.d.ts","sourceRoot":"","sources":["../src/swatches.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAGtC,eAAO,MAAM,kBAAkB,iEAAkE,CAAC;AAClG,MAAM,MAAM,eAAe,GAAG,CAAC,OAAO,kBAAkB,CAAC,CAAC,MAAM,CAAC,CAAC;AAElE,eAAO,MAAM,sBAAsB,EAAG,GAAY,CAAC;AAEnD,MAAM,MAAM,WAAW,GAAG,MAAM,CAAC,eAAe,EAAE,QAAQ,CAAC,CAAC;AAE5D,MAAM,MAAM,sBAAsB,GAC9B,WAAW,GACX,qBAAqB,GACrB,yBAAyB,CAAC;AAE9B,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,sBAAsB,CAAC;IAC7B,IAAI,CAAC,EAAE,eAAe,CAAC;IACvB,OAAO,EAAE,MAAM,CAAC;IAChB,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,sBAAsB;IACrC,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE,SAAS,kBAAkB,EAAE,CAAC;IACxC,gBAAgB,EAAE,MAAM,CAAC;IACzB,gBAAgB,EAAE,MAAM,CAAC;IACzB,cAAc,EAAE;QACd,GAAG,EAAE,MAAM,CAAC;QACZ,GAAG,EAAE,MAAM,CAAC;KACb,CAAC;CACH;AAsCD;;EAEE;AACF,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,QAAQ,GAAG;IACxD,MAAM,EAAE,WAAW,CAAC;IACpB,WAAW,EAAE,sBAAsB,CAAC;CACrC,CAoFA"}
1
+ {"version":3,"file":"swatches.d.ts","sourceRoot":"","sources":["../src/swatches.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAGtC,eAAO,MAAM,kBAAkB,iEAAkE,CAAC;AAClG,MAAM,MAAM,eAAe,GAAG,CAAC,OAAO,kBAAkB,CAAC,CAAC,MAAM,CAAC,CAAC;AAElE,eAAO,MAAM,sBAAsB,EAAG,GAAY,CAAC;AACnD,eAAO,MAAM,2BAA2B,QAAQ,CAAC;AAEjD,MAAM,MAAM,WAAW,GAAG,MAAM,CAAC,eAAe,EAAE,QAAQ,CAAC,CAAC;AAE5D,MAAM,MAAM,sBAAsB,GAChC,WAAW,GAAG,qBAAqB,GAAG,yBAAyB,CAAC;AAElE,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,sBAAsB,CAAC;IAC7B,IAAI,CAAC,EAAE,eAAe,CAAC;IACvB,OAAO,EAAE,MAAM,CAAC;IAChB,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,sBAAsB;IACrC,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE,SAAS,kBAAkB,EAAE,CAAC;IACxC,gBAAgB,EAAE,MAAM,CAAC;IACzB,gBAAgB,EAAE,MAAM,CAAC;IACzB,cAAc,EAAE;QACd,GAAG,EAAE,MAAM,CAAC;QACZ,GAAG,EAAE,MAAM,CAAC;KACb,CAAC;CACH;AAgLD;;EAEE;AACF,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,QAAQ,GAAG;IACxD,MAAM,EAAE,WAAW,CAAC;IACpB,WAAW,EAAE,sBAAsB,CAAC;CACrC,CAaA"}
package/dist/swatches.js CHANGED
@@ -1,19 +1,20 @@
1
1
  import { deltaEoklch, oklchToHex, parseHexToOklch } from './internal-culori';
2
2
  export const COLOR_SWATCH_STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
3
3
  export const COLOR_SWATCH_BASE_STEP = 500;
4
- const BASELINE_LIGHTNESS_BY_STEP = {
5
- 50: 0.985,
6
- 100: 0.967,
7
- 200: 0.928,
8
- 300: 0.872,
9
- 400: 0.707,
10
- 500: 0.551,
11
- 600: 0.446,
12
- 700: 0.373,
13
- 800: 0.278,
14
- 900: 0.21,
15
- 950: 0.13,
16
- };
4
+ export const COLOR_SWATCH_BASE_LIGHTNESS = 0.551;
5
+ const BASELINE_LIGHTNESS_BY_STEP = new Map([
6
+ [50, 0.985],
7
+ [100, 0.967],
8
+ [200, 0.928],
9
+ [300, 0.872],
10
+ [400, 0.707],
11
+ [COLOR_SWATCH_BASE_STEP, COLOR_SWATCH_BASE_LIGHTNESS],
12
+ [600, 0.446],
13
+ [700, 0.373],
14
+ [800, 0.278],
15
+ [900, 0.21],
16
+ [950, 0.13],
17
+ ]);
17
18
  const MIN_USABLE_ADJACENT_DELTA = 0.012;
18
19
  const MIN_USABLE_LIGHTNESS_RANGE = 0.35;
19
20
  /***
@@ -39,80 +40,125 @@ function chromaMultiplierForStep(step) {
39
40
  return 0.8;
40
41
  }
41
42
  /***
42
- Generate a full color swatch and diagnostics from a base color.
43
+ Resolve the canonical baseline lightness for a swatch step.
43
44
  */
44
- export function generateColorSwatch(baseColor) {
45
- const baseOklch = parseHexToOklch(baseColor);
46
- const baseOffset = baseOklch.l - BASELINE_LIGHTNESS_BY_STEP[COLOR_SWATCH_BASE_STEP];
47
- const warnings = [];
48
- const swatch = {};
49
- const lightnessEntries = [];
50
- const adjacentDeltas = [];
51
- for (const step of COLOR_SWATCH_STEPS) {
52
- if (step === COLOR_SWATCH_BASE_STEP) {
53
- swatch[step] = baseColor;
54
- lightnessEntries.push(baseOklch.l);
55
- continue;
56
- }
57
- const targetL = clamp01(BASELINE_LIGHTNESS_BY_STEP[step] + baseOffset);
58
- const targetC = clamp01(baseOklch.c * chromaMultiplierForStep(step));
59
- const target = { ...baseOklch, l: targetL, c: targetC };
60
- const hex = oklchToHex(target);
61
- swatch[step] = hex;
62
- const candidateOklch = parseHexToOklch(hex);
63
- lightnessEntries.push(candidateOklch.l);
64
- const deltaEFromBase = deltaEoklch(baseOklch, candidateOklch);
65
- if (deltaEFromBase < 0.02) {
66
- warnings.push({
67
- code: 'weak_step',
68
- step,
69
- deltaEFromBase,
70
- message: `Swatch step ${step} is visually close to the base color.`,
71
- });
72
- }
45
+ function getBaselineLightness(step) {
46
+ const lightness = BASELINE_LIGHTNESS_BY_STEP.get(step);
47
+ if (lightness === undefined) {
48
+ throw new Error(`[color-theory] Missing baseline lightness for swatch step ${step}.`);
73
49
  }
74
- for (let index = 1; index < COLOR_SWATCH_STEPS.length; index++) {
75
- const previousStep = COLOR_SWATCH_STEPS[index - 1];
76
- const currentStep = COLOR_SWATCH_STEPS[index];
77
- if (previousStep === undefined || currentStep === undefined)
78
- continue;
79
- const previous = parseHexToOklch(swatch[previousStep]);
80
- const current = parseHexToOklch(swatch[currentStep]);
81
- const adjacentDelta = deltaEoklch(previous, current);
82
- adjacentDeltas.push(adjacentDelta);
83
- if (adjacentDelta < MIN_USABLE_ADJACENT_DELTA) {
84
- warnings.push({
85
- code: 'weak_adjacent_delta',
86
- step: currentStep,
87
- deltaEFromBase: adjacentDelta,
88
- message: `Swatch step ${currentStep} is visually close to adjacent step ${previousStep}.`,
89
- });
90
- }
50
+ return lightness;
51
+ }
52
+ /***
53
+ Generate one immutable swatch sample from the base color.
54
+ */
55
+ function createSwatchSample(baseColor, step, baseOffset) {
56
+ const base = parseHexToOklch(baseColor);
57
+ if (step === COLOR_SWATCH_BASE_STEP) {
58
+ return { hex: baseColor, lightness: base.l, step };
91
59
  }
60
+ const target = {
61
+ ...base,
62
+ l: clamp01(getBaselineLightness(step) + baseOffset),
63
+ c: clamp01(base.c * chromaMultiplierForStep(step)),
64
+ };
65
+ const hex = oklchToHex(target);
66
+ return { hex, lightness: parseHexToOklch(hex).l, step };
67
+ }
68
+ /***
69
+ Create any weak-step warning for a generated sample.
70
+ */
71
+ function createWeakStepWarning(baseColor, sample) {
72
+ if (sample.step === COLOR_SWATCH_BASE_STEP)
73
+ return undefined;
74
+ const deltaEFromBase = deltaEoklch(parseHexToOklch(baseColor), parseHexToOklch(sample.hex));
75
+ if (deltaEFromBase >= 0.02)
76
+ return undefined;
77
+ return {
78
+ code: 'weak_step',
79
+ step: sample.step,
80
+ deltaEFromBase,
81
+ message: `Swatch step ${sample.step} is visually close to the base color.`,
82
+ };
83
+ }
84
+ /***
85
+ Generate immutable swatch samples and their per-step warnings.
86
+ */
87
+ function generateSwatchSamples(baseColor) {
88
+ const baseOffset = parseHexToOklch(baseColor).l - COLOR_SWATCH_BASE_LIGHTNESS;
89
+ const samples = COLOR_SWATCH_STEPS.map((step) => createSwatchSample(baseColor, step, baseOffset));
90
+ const warnings = samples
91
+ .map((sample) => createWeakStepWarning(baseColor, sample))
92
+ .filter((warning) => warning !== undefined);
93
+ return { samples, warnings };
94
+ }
95
+ /***
96
+ Measure adjacent swatch samples and report weak transitions.
97
+ */
98
+ function measureAdjacentSamples(samples) {
99
+ const pairs = samples
100
+ .slice(1)
101
+ .map((current, index) => ({ current, previous: samples.at(index) }));
102
+ const measured = pairs.flatMap(({ current, previous }) => {
103
+ if (!previous)
104
+ return [];
105
+ const delta = deltaEoklch(parseHexToOklch(previous.hex), parseHexToOklch(current.hex));
106
+ const warning = delta < MIN_USABLE_ADJACENT_DELTA
107
+ ? {
108
+ code: 'weak_adjacent_delta',
109
+ step: current.step,
110
+ deltaEFromBase: delta,
111
+ message: `Swatch step ${current.step} is visually close to adjacent step ${previous.step}.`,
112
+ }
113
+ : undefined;
114
+ return [{ delta, warning }];
115
+ });
116
+ return {
117
+ deltas: measured.map(({ delta }) => delta),
118
+ warnings: measured
119
+ .map(({ warning }) => warning)
120
+ .filter((warning) => warning !== undefined),
121
+ };
122
+ }
123
+ /***
124
+ Create diagnostics from generated samples and their adjacent deltas.
125
+ */
126
+ function createSwatchDiagnostics(samples, adjacentDeltas, warnings) {
92
127
  const minAdjacentDelta = Math.min(...adjacentDeltas);
93
128
  const maxAdjacentDelta = Math.max(...adjacentDeltas);
129
+ const lightnessEntries = samples.map(({ lightness }) => lightness);
94
130
  const minLightness = Math.min(...lightnessEntries);
95
131
  const maxLightness = Math.max(...lightnessEntries);
96
132
  const lightnessRange = maxLightness - minLightness;
97
- if (lightnessRange < MIN_USABLE_LIGHTNESS_RANGE) {
98
- warnings.push({
99
- code: 'limited_lightness_range',
100
- message: 'Generated swatch has a limited lightness range.',
101
- });
102
- }
133
+ const rangeWarnings = lightnessRange < MIN_USABLE_LIGHTNESS_RANGE
134
+ ? [
135
+ {
136
+ code: 'limited_lightness_range',
137
+ message: 'Generated swatch has a limited lightness range.',
138
+ },
139
+ ]
140
+ : [];
141
+ return {
142
+ isUsable: minAdjacentDelta >= MIN_USABLE_ADJACENT_DELTA && lightnessRange >= MIN_USABLE_LIGHTNESS_RANGE,
143
+ warnings: [...warnings, ...rangeWarnings],
144
+ minAdjacentDelta,
145
+ maxAdjacentDelta,
146
+ lightnessRange: { min: minLightness, max: maxLightness },
147
+ };
148
+ }
149
+ /***
150
+ Generate a full color swatch and diagnostics from a base color.
151
+ */
152
+ export function generateColorSwatch(baseColor) {
153
+ const generated = generateSwatchSamples(baseColor);
154
+ const adjacent = measureAdjacentSamples(generated.samples);
155
+ const swatch = Object.fromEntries(generated.samples.map(({ hex, step }) => [step, hex]));
103
156
  return {
104
157
  swatch,
105
- diagnostics: {
106
- isUsable: minAdjacentDelta >= MIN_USABLE_ADJACENT_DELTA &&
107
- lightnessRange >= MIN_USABLE_LIGHTNESS_RANGE,
108
- warnings,
109
- minAdjacentDelta,
110
- maxAdjacentDelta,
111
- lightnessRange: {
112
- min: minLightness,
113
- max: maxLightness,
114
- },
115
- },
158
+ diagnostics: createSwatchDiagnostics(generated.samples, adjacent.deltas, [
159
+ ...generated.warnings,
160
+ ...adjacent.warnings,
161
+ ]),
116
162
  };
117
163
  }
118
164
  //# sourceMappingURL=swatches.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"swatches.js","sourceRoot":"","sources":["../src/swatches.ts"],"names":[],"mappings":"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","sourcesContent":["import type { HexColor } from './hex';\nimport { deltaEoklch, oklchToHex, parseHexToOklch } from './internal-culori';\n\nexport const COLOR_SWATCH_STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] as const;\nexport type ColorSwatchStep = (typeof COLOR_SWATCH_STEPS)[number];\n\nexport const COLOR_SWATCH_BASE_STEP = 500 as const;\n\nexport type ColorSwatch = Record<ColorSwatchStep, HexColor>;\n\nexport type ColorSwatchWarningCode =\n | 'weak_step'\n | 'weak_adjacent_delta'\n | 'limited_lightness_range';\n\nexport interface ColorSwatchWarning {\n code: ColorSwatchWarningCode;\n step?: ColorSwatchStep;\n message: string;\n deltaEFromBase?: number;\n}\n\nexport interface ColorSwatchDiagnostics {\n isUsable: boolean;\n warnings: readonly ColorSwatchWarning[];\n minAdjacentDelta: number;\n maxAdjacentDelta: number;\n lightnessRange: {\n min: number;\n max: number;\n };\n}\n\nconst BASELINE_LIGHTNESS_BY_STEP: Record<ColorSwatchStep, number> = {\n 50: 0.985,\n 100: 0.967,\n 200: 0.928,\n 300: 0.872,\n 400: 0.707,\n 500: 0.551,\n 600: 0.446,\n 700: 0.373,\n 800: 0.278,\n 900: 0.21,\n 950: 0.13,\n};\n\nconst MIN_USABLE_ADJACENT_DELTA = 0.012;\nconst MIN_USABLE_LIGHTNESS_RANGE = 0.35;\n\n/***\n Clamp a number into the normalized zero-to-one range.\n*/\nfunction clamp01(value: number): number {\n if (!Number.isFinite(value)) return 0;\n return Math.min(1, Math.max(0, value));\n}\n\n/***\n Return the chroma multiplier used for a swatch step.\n*/\nfunction chromaMultiplierForStep(step: ColorSwatchStep): number {\n if (step <= 200) return 0.55;\n if (step <= 400) return 0.75;\n if (step === COLOR_SWATCH_BASE_STEP) return 1;\n if (step <= 700) return 0.9;\n return 0.8;\n}\n\n/***\n Generate a full color swatch and diagnostics from a base color.\n*/\nexport function generateColorSwatch(baseColor: HexColor): {\n swatch: ColorSwatch;\n diagnostics: ColorSwatchDiagnostics;\n} {\n const baseOklch = parseHexToOklch(baseColor);\n const baseOffset = baseOklch.l - BASELINE_LIGHTNESS_BY_STEP[COLOR_SWATCH_BASE_STEP];\n\n const warnings: ColorSwatchWarning[] = [];\n const swatch = {} as ColorSwatch;\n const lightnessEntries: number[] = [];\n const adjacentDeltas: number[] = [];\n\n for (const step of COLOR_SWATCH_STEPS) {\n if (step === COLOR_SWATCH_BASE_STEP) {\n swatch[step] = baseColor;\n lightnessEntries.push(baseOklch.l);\n continue;\n }\n\n const targetL = clamp01(BASELINE_LIGHTNESS_BY_STEP[step] + baseOffset);\n const targetC = clamp01(baseOklch.c * chromaMultiplierForStep(step));\n const target = { ...baseOklch, l: targetL, c: targetC };\n const hex = oklchToHex(target);\n swatch[step] = hex;\n\n const candidateOklch = parseHexToOklch(hex);\n lightnessEntries.push(candidateOklch.l);\n\n const deltaEFromBase = deltaEoklch(baseOklch, candidateOklch);\n if (deltaEFromBase < 0.02) {\n warnings.push({\n code: 'weak_step',\n step,\n deltaEFromBase,\n message: `Swatch step ${step} is visually close to the base color.`,\n });\n }\n }\n\n for (let index = 1; index < COLOR_SWATCH_STEPS.length; index++) {\n const previousStep = COLOR_SWATCH_STEPS[index - 1];\n const currentStep = COLOR_SWATCH_STEPS[index];\n if (previousStep === undefined || currentStep === undefined) continue;\n\n const previous = parseHexToOklch(swatch[previousStep]);\n const current = parseHexToOklch(swatch[currentStep]);\n const adjacentDelta = deltaEoklch(previous, current);\n adjacentDeltas.push(adjacentDelta);\n\n if (adjacentDelta < MIN_USABLE_ADJACENT_DELTA) {\n warnings.push({\n code: 'weak_adjacent_delta',\n step: currentStep,\n deltaEFromBase: adjacentDelta,\n message: `Swatch step ${currentStep} is visually close to adjacent step ${previousStep}.`,\n });\n }\n }\n\n const minAdjacentDelta = Math.min(...adjacentDeltas);\n const maxAdjacentDelta = Math.max(...adjacentDeltas);\n const minLightness = Math.min(...lightnessEntries);\n const maxLightness = Math.max(...lightnessEntries);\n const lightnessRange = maxLightness - minLightness;\n\n if (lightnessRange < MIN_USABLE_LIGHTNESS_RANGE) {\n warnings.push({\n code: 'limited_lightness_range',\n message: 'Generated swatch has a limited lightness range.',\n });\n }\n\n return {\n swatch,\n diagnostics: {\n isUsable:\n minAdjacentDelta >= MIN_USABLE_ADJACENT_DELTA &&\n lightnessRange >= MIN_USABLE_LIGHTNESS_RANGE,\n warnings,\n minAdjacentDelta,\n maxAdjacentDelta,\n lightnessRange: {\n min: minLightness,\n max: maxLightness,\n },\n },\n };\n}\n"]}
1
+ {"version":3,"file":"swatches.js","sourceRoot":"","sources":["../src/swatches.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAE7E,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,EAAE,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,CAAU,CAAC;AAGlG,MAAM,CAAC,MAAM,sBAAsB,GAAG,GAAY,CAAC;AACnD,MAAM,CAAC,MAAM,2BAA2B,GAAG,KAAK,CAAC;AAyBjD,MAAM,0BAA0B,GAAG,IAAI,GAAG,CAA0B;IAClE,CAAC,EAAE,EAAE,KAAK,CAAC;IACX,CAAC,GAAG,EAAE,KAAK,CAAC;IACZ,CAAC,GAAG,EAAE,KAAK,CAAC;IACZ,CAAC,GAAG,EAAE,KAAK,CAAC;IACZ,CAAC,GAAG,EAAE,KAAK,CAAC;IACZ,CAAC,sBAAsB,EAAE,2BAA2B,CAAC;IACrD,CAAC,GAAG,EAAE,KAAK,CAAC;IACZ,CAAC,GAAG,EAAE,KAAK,CAAC;IACZ,CAAC,GAAG,EAAE,KAAK,CAAC;IACZ,CAAC,GAAG,EAAE,IAAI,CAAC;IACX,CAAC,GAAG,EAAE,IAAI,CAAC;CACZ,CAAC,CAAC;AAEH,MAAM,yBAAyB,GAAG,KAAK,CAAC;AACxC,MAAM,0BAA0B,GAAG,IAAI,CAAC;AAaxC;;EAEE;AACF,SAAS,OAAO,CAAC,KAAa;IAC5B,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC;QAAE,OAAO,CAAC,CAAC;IACtC,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC,CAAC;AACzC,CAAC;AAED;;EAEE;AACF,SAAS,uBAAuB,CAAC,IAAqB;IACpD,IAAI,IAAI,IAAI,GAAG;QAAE,OAAO,IAAI,CAAC;IAC7B,IAAI,IAAI,IAAI,GAAG;QAAE,OAAO,IAAI,CAAC;IAC7B,IAAI,IAAI,KAAK,sBAAsB;QAAE,OAAO,CAAC,CAAC;IAC9C,IAAI,IAAI,IAAI,GAAG;QAAE,OAAO,GAAG,CAAC;IAC5B,OAAO,GAAG,CAAC;AACb,CAAC;AAED;;EAEE;AACF,SAAS,oBAAoB,CAAC,IAAqB;IACjD,MAAM,SAAS,GAAG,0BAA0B,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IACvD,IAAI,SAAS,KAAK,SAAS,EAAE,CAAC;QAC5B,MAAM,IAAI,KAAK,CAAC,6DAA6D,IAAI,GAAG,CAAC,CAAC;IACxF,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC;AAED;;EAEE;AACF,SAAS,kBAAkB,CACzB,SAAmB,EACnB,IAAqB,EACrB,UAAkB;IAElB,MAAM,IAAI,GAAG,eAAe,CAAC,SAAS,CAAC,CAAC;IACxC,IAAI,IAAI,KAAK,sBAAsB,EAAE,CAAC;QACpC,OAAO,EAAE,GAAG,EAAE,SAAS,EAAE,SAAS,EAAE,IAAI,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC;IACrD,CAAC;IACD,MAAM,MAAM,GAAG;QACb,GAAG,IAAI;QACP,CAAC,EAAE,OAAO,CAAC,oBAAoB,CAAC,IAAI,CAAC,GAAG,UAAU,CAAC;QACnD,CAAC,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC,GAAG,uBAAuB,CAAC,IAAI,CAAC,CAAC;KACnD,CAAC;IACF,MAAM,GAAG,GAAG,UAAU,CAAC,MAAM,CAAC,CAAC;IAC/B,OAAO,EAAE,GAAG,EAAE,SAAS,EAAE,eAAe,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC;AAC1D,CAAC;AAED;;EAEE;AACF,SAAS,qBAAqB,CAC5B,SAAmB,EACnB,MAAyB;IAEzB,IAAI,MAAM,CAAC,IAAI,KAAK,sBAAsB;QAAE,OAAO,SAAS,CAAC;IAC7D,MAAM,cAAc,GAAG,WAAW,CAAC,eAAe,CAAC,SAAS,CAAC,EAAE,eAAe,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC;IAC5F,IAAI,cAAc,IAAI,IAAI;QAAE,OAAO,SAAS,CAAC;IAC7C,OAAO;QACL,IAAI,EAAE,WAAW;QACjB,IAAI,EAAE,MAAM,CAAC,IAAI;QACjB,cAAc;QACd,OAAO,EAAE,eAAe,MAAM,CAAC,IAAI,uCAAuC;KAC3E,CAAC;AACJ,CAAC;AAED;;EAEE;AACF,SAAS,qBAAqB,CAAC,SAAmB;IAChD,MAAM,UAAU,GAAG,eAAe,CAAC,SAAS,CAAC,CAAC,CAAC,GAAG,2BAA2B,CAAC;IAC9E,MAAM,OAAO,GAAG,kBAAkB,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,kBAAkB,CAAC,SAAS,EAAE,IAAI,EAAE,UAAU,CAAC,CAAC,CAAC;IAClG,MAAM,QAAQ,GAAG,OAAO;SACrB,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,qBAAqB,CAAC,SAAS,EAAE,MAAM,CAAC,CAAC;SACzD,MAAM,CAAC,CAAC,OAAO,EAAiC,EAAE,CAAC,OAAO,KAAK,SAAS,CAAC,CAAC;IAC7E,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC;AAC/B,CAAC;AAED;;EAEE;AACF,SAAS,sBAAsB,CAAC,OAAqC;IAInE,MAAM,KAAK,GAAG,OAAO;SAClB,KAAK,CAAC,CAAC,CAAC;SACR,GAAG,CAAC,CAAC,OAAO,EAAE,KAAK,EAAE,EAAE,CAAC,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,CAAC;IACvE,MAAM,QAAQ,GAAG,KAAK,CAAC,OAAO,CAAC,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE;QACvD,IAAI,CAAC,QAAQ;YAAE,OAAO,EAAE,CAAC;QACzB,MAAM,KAAK,GAAG,WAAW,CAAC,eAAe,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,eAAe,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC;QACvF,MAAM,OAAO,GACX,KAAK,GAAG,yBAAyB;YAC/B,CAAC,CAAC;gBACE,IAAI,EAAE,qBAAqB;gBAC3B,IAAI,EAAE,OAAO,CAAC,IAAI;gBAClB,cAAc,EAAE,KAAK;gBACrB,OAAO,EAAE,eAAe,OAAO,CAAC,IAAI,uCAAuC,QAAQ,CAAC,IAAI,GAAG;aAC5F;YACH,CAAC,CAAC,SAAS,CAAC;QAChB,OAAO,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC,CAAC;IAC9B,CAAC,CAAC,CAAC;IACH,OAAO;QACL,MAAM,EAAE,QAAQ,CAAC,GAAG,CAAC,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC,KAAK,CAAC;QAC1C,QAAQ,EAAE,QAAQ;aACf,GAAG,CAAC,CAAC,EAAE,OAAO,EAAE,EAAE,EAAE,CAAC,OAAO,CAAC;aAC7B,MAAM,CAAC,CAAC,OAAO,EAAiC,EAAE,CAAC,OAAO,KAAK,SAAS,CAAC;KAC7E,CAAC;AACJ,CAAC;AAED;;EAEE;AACF,SAAS,uBAAuB,CAC9B,OAAqC,EACrC,cAAiC,EACjC,QAAuC;IAEvC,MAAM,gBAAgB,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,cAAc,CAAC,CAAC;IACrD,MAAM,gBAAgB,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,cAAc,CAAC,CAAC;IACrD,MAAM,gBAAgB,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,EAAE,SAAS,EAAE,EAAE,EAAE,CAAC,SAAS,CAAC,CAAC;IACnE,MAAM,YAAY,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,gBAAgB,CAAC,CAAC;IACnD,MAAM,YAAY,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,gBAAgB,CAAC,CAAC;IACnD,MAAM,cAAc,GAAG,YAAY,GAAG,YAAY,CAAC;IACnD,MAAM,aAAa,GACjB,cAAc,GAAG,0BAA0B;QACzC,CAAC,CAAC;YACE;gBACE,IAAI,EAAE,yBAAyB;gBAC/B,OAAO,EAAE,iDAAiD;aAC3D;SACF;QACH,CAAC,CAAC,EAAE,CAAC;IACT,OAAO;QACL,QAAQ,EACN,gBAAgB,IAAI,yBAAyB,IAAI,cAAc,IAAI,0BAA0B;QAC/F,QAAQ,EAAE,CAAC,GAAG,QAAQ,EAAE,GAAG,aAAa,CAAC;QACzC,gBAAgB;QAChB,gBAAgB;QAChB,cAAc,EAAE,EAAE,GAAG,EAAE,YAAY,EAAE,GAAG,EAAE,YAAY,EAAE;KACzD,CAAC;AACJ,CAAC;AAED;;EAEE;AACF,MAAM,UAAU,mBAAmB,CAAC,SAAmB;IAIrD,MAAM,SAAS,GAAG,qBAAqB,CAAC,SAAS,CAAC,CAAC;IACnD,MAAM,QAAQ,GAAG,sBAAsB,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC;IAC3D,MAAM,MAAM,GAAG,MAAM,CAAC,WAAW,CAC/B,SAAS,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,CACvC,CAAC;IACjB,OAAO;QACL,MAAM;QACN,WAAW,EAAE,uBAAuB,CAAC,SAAS,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,EAAE;YACvE,GAAG,SAAS,CAAC,QAAQ;YACrB,GAAG,QAAQ,CAAC,QAAQ;SACrB,CAAC;KACH,CAAC;AACJ,CAAC","sourcesContent":["import type { HexColor } from './hex';\nimport { deltaEoklch, oklchToHex, parseHexToOklch } from './internal-culori';\n\nexport const COLOR_SWATCH_STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] as const;\nexport type ColorSwatchStep = (typeof COLOR_SWATCH_STEPS)[number];\n\nexport const COLOR_SWATCH_BASE_STEP = 500 as const;\nexport const COLOR_SWATCH_BASE_LIGHTNESS = 0.551;\n\nexport type ColorSwatch = Record<ColorSwatchStep, HexColor>;\n\nexport type ColorSwatchWarningCode =\n 'weak_step' | 'weak_adjacent_delta' | 'limited_lightness_range';\n\nexport interface ColorSwatchWarning {\n code: ColorSwatchWarningCode;\n step?: ColorSwatchStep;\n message: string;\n deltaEFromBase?: number;\n}\n\nexport interface ColorSwatchDiagnostics {\n isUsable: boolean;\n warnings: readonly ColorSwatchWarning[];\n minAdjacentDelta: number;\n maxAdjacentDelta: number;\n lightnessRange: {\n min: number;\n max: number;\n };\n}\n\nconst BASELINE_LIGHTNESS_BY_STEP = new Map<ColorSwatchStep, number>([\n [50, 0.985],\n [100, 0.967],\n [200, 0.928],\n [300, 0.872],\n [400, 0.707],\n [COLOR_SWATCH_BASE_STEP, COLOR_SWATCH_BASE_LIGHTNESS],\n [600, 0.446],\n [700, 0.373],\n [800, 0.278],\n [900, 0.21],\n [950, 0.13],\n]);\n\nconst MIN_USABLE_ADJACENT_DELTA = 0.012;\nconst MIN_USABLE_LIGHTNESS_RANGE = 0.35;\n\ninterface ColorSwatchSample {\n readonly hex: HexColor;\n readonly lightness: number;\n readonly step: ColorSwatchStep;\n}\n\ninterface GeneratedColorSwatchSamples {\n readonly samples: readonly ColorSwatchSample[];\n readonly warnings: readonly ColorSwatchWarning[];\n}\n\n/***\n Clamp a number into the normalized zero-to-one range.\n*/\nfunction clamp01(value: number): number {\n if (!Number.isFinite(value)) return 0;\n return Math.min(1, Math.max(0, value));\n}\n\n/***\n Return the chroma multiplier used for a swatch step.\n*/\nfunction chromaMultiplierForStep(step: ColorSwatchStep): number {\n if (step <= 200) return 0.55;\n if (step <= 400) return 0.75;\n if (step === COLOR_SWATCH_BASE_STEP) return 1;\n if (step <= 700) return 0.9;\n return 0.8;\n}\n\n/***\n Resolve the canonical baseline lightness for a swatch step.\n*/\nfunction getBaselineLightness(step: ColorSwatchStep): number {\n const lightness = BASELINE_LIGHTNESS_BY_STEP.get(step);\n if (lightness === undefined) {\n throw new Error(`[color-theory] Missing baseline lightness for swatch step ${step}.`);\n }\n return lightness;\n}\n\n/***\n Generate one immutable swatch sample from the base color.\n*/\nfunction createSwatchSample(\n baseColor: HexColor,\n step: ColorSwatchStep,\n baseOffset: number,\n): ColorSwatchSample {\n const base = parseHexToOklch(baseColor);\n if (step === COLOR_SWATCH_BASE_STEP) {\n return { hex: baseColor, lightness: base.l, step };\n }\n const target = {\n ...base,\n l: clamp01(getBaselineLightness(step) + baseOffset),\n c: clamp01(base.c * chromaMultiplierForStep(step)),\n };\n const hex = oklchToHex(target);\n return { hex, lightness: parseHexToOklch(hex).l, step };\n}\n\n/***\n Create any weak-step warning for a generated sample.\n*/\nfunction createWeakStepWarning(\n baseColor: HexColor,\n sample: ColorSwatchSample,\n): ColorSwatchWarning | undefined {\n if (sample.step === COLOR_SWATCH_BASE_STEP) return undefined;\n const deltaEFromBase = deltaEoklch(parseHexToOklch(baseColor), parseHexToOklch(sample.hex));\n if (deltaEFromBase >= 0.02) return undefined;\n return {\n code: 'weak_step',\n step: sample.step,\n deltaEFromBase,\n message: `Swatch step ${sample.step} is visually close to the base color.`,\n };\n}\n\n/***\n Generate immutable swatch samples and their per-step warnings.\n*/\nfunction generateSwatchSamples(baseColor: HexColor): GeneratedColorSwatchSamples {\n const baseOffset = parseHexToOklch(baseColor).l - COLOR_SWATCH_BASE_LIGHTNESS;\n const samples = COLOR_SWATCH_STEPS.map((step) => createSwatchSample(baseColor, step, baseOffset));\n const warnings = samples\n .map((sample) => createWeakStepWarning(baseColor, sample))\n .filter((warning): warning is ColorSwatchWarning => warning !== undefined);\n return { samples, warnings };\n}\n\n/***\n Measure adjacent swatch samples and report weak transitions.\n*/\nfunction measureAdjacentSamples(samples: readonly ColorSwatchSample[]): {\n readonly deltas: readonly number[];\n readonly warnings: readonly ColorSwatchWarning[];\n} {\n const pairs = samples\n .slice(1)\n .map((current, index) => ({ current, previous: samples.at(index) }));\n const measured = pairs.flatMap(({ current, previous }) => {\n if (!previous) return [];\n const delta = deltaEoklch(parseHexToOklch(previous.hex), parseHexToOklch(current.hex));\n const warning: ColorSwatchWarning | undefined =\n delta < MIN_USABLE_ADJACENT_DELTA\n ? {\n code: 'weak_adjacent_delta',\n step: current.step,\n deltaEFromBase: delta,\n message: `Swatch step ${current.step} is visually close to adjacent step ${previous.step}.`,\n }\n : undefined;\n return [{ delta, warning }];\n });\n return {\n deltas: measured.map(({ delta }) => delta),\n warnings: measured\n .map(({ warning }) => warning)\n .filter((warning): warning is ColorSwatchWarning => warning !== undefined),\n };\n}\n\n/***\n Create diagnostics from generated samples and their adjacent deltas.\n*/\nfunction createSwatchDiagnostics(\n samples: readonly ColorSwatchSample[],\n adjacentDeltas: readonly number[],\n warnings: readonly ColorSwatchWarning[],\n): ColorSwatchDiagnostics {\n const minAdjacentDelta = Math.min(...adjacentDeltas);\n const maxAdjacentDelta = Math.max(...adjacentDeltas);\n const lightnessEntries = samples.map(({ lightness }) => lightness);\n const minLightness = Math.min(...lightnessEntries);\n const maxLightness = Math.max(...lightnessEntries);\n const lightnessRange = maxLightness - minLightness;\n const rangeWarnings: readonly ColorSwatchWarning[] =\n lightnessRange < MIN_USABLE_LIGHTNESS_RANGE\n ? [\n {\n code: 'limited_lightness_range',\n message: 'Generated swatch has a limited lightness range.',\n },\n ]\n : [];\n return {\n isUsable:\n minAdjacentDelta >= MIN_USABLE_ADJACENT_DELTA && lightnessRange >= MIN_USABLE_LIGHTNESS_RANGE,\n warnings: [...warnings, ...rangeWarnings],\n minAdjacentDelta,\n maxAdjacentDelta,\n lightnessRange: { min: minLightness, max: maxLightness },\n };\n}\n\n/***\n Generate a full color swatch and diagnostics from a base color.\n*/\nexport function generateColorSwatch(baseColor: HexColor): {\n swatch: ColorSwatch;\n diagnostics: ColorSwatchDiagnostics;\n} {\n const generated = generateSwatchSamples(baseColor);\n const adjacent = measureAdjacentSamples(generated.samples);\n const swatch = Object.fromEntries(\n generated.samples.map(({ hex, step }) => [step, hex]),\n ) as ColorSwatch;\n return {\n swatch,\n diagnostics: createSwatchDiagnostics(generated.samples, adjacent.deltas, [\n ...generated.warnings,\n ...adjacent.warnings,\n ]),\n };\n}\n"]}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ankhorage/color-theory",
3
3
  "type": "module",
4
- "version": "0.0.7",
4
+ "version": "0.1.0",
5
5
  "description": "Standalone color theory, harmony, swatch, contrast, and theme color generation utilities.",
6
6
  "homepage": "https://github.com/ankhorage/color-theory#readme",
7
7
  "bugs": {
@@ -59,9 +59,8 @@
59
59
  },
60
60
  "scripts": {
61
61
  "build": "rm -rf dist tsconfig.tsbuildinfo && bun x tsc -p tsconfig.json",
62
- "changeset": "changeset",
63
- "changeset:status": "changeset status --since=origin/main",
64
- "knip": "ankhorage-knip",
62
+ "changeset": "ankhorage-changeset",
63
+ "changeset:status": "ankhorage-changeset status --since=origin/main",
65
64
  "lint": "ankhorage-eslint . --max-warnings=0",
66
65
  "lint:fix": "ankhorage-eslint . --fix --max-warnings=0",
67
66
  "format": "ankhorage-prettier --write .",
@@ -70,16 +69,16 @@
70
69
  "prepack": "bun run build",
71
70
  "test": "bun test src",
72
71
  "typecheck": "bun x tsc --noEmit -p tsconfig.json",
73
- "version-packages": "changeset version"
72
+ "version-packages": "ankhorage-changeset version",
73
+ "knip:check": "ankhorage-knip"
74
74
  },
75
75
  "devDependencies": {
76
- "@ankhorage/devtools": "^1.0.6",
77
- "@ankhorage/paradox": "^0.1.6",
78
- "@changesets/cli": "^2.31.0",
79
- "@types/bun": "^1.3.13",
76
+ "@ankhorage/devtools": "^1.9.4",
77
+ "@ankhorage/paradox": "^0.1.24",
78
+ "@types/bun": "^1.4.0",
80
79
  "@types/culori": "^4.0.1",
81
80
  "@types/node": "^25.6.0",
82
81
  "typescript": "^5.9.3"
83
82
  },
84
- "packageManager": "bun@1.3.13"
83
+ "packageManager": "bun@1.4.0"
85
84
  }