@wordpress/theme 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.
Files changed (168) hide show
  1. package/LICENSE.md +788 -0
  2. package/README.md +67 -0
  3. package/bin/build-tokens.js +83 -0
  4. package/bin/generate-primitive-tokens/index.ts +115 -0
  5. package/bin/terrazzo-plugin-ds-tokens-docs/index.ts +103 -0
  6. package/bin/terrazzo-plugin-figma-ds-token-manager/index.ts +210 -0
  7. package/bin/terrazzo-plugin-figma-ds-token-manager/lib.ts +1 -0
  8. package/bin/terrazzo-plugin-known-wpds-css-variables/index.ts +72 -0
  9. package/build/color-ramps/index.js +132 -0
  10. package/build/color-ramps/index.js.map +7 -0
  11. package/build/color-ramps/lib/cache-utils.js +57 -0
  12. package/build/color-ramps/lib/cache-utils.js.map +7 -0
  13. package/build/color-ramps/lib/constants.js +105 -0
  14. package/build/color-ramps/lib/constants.js.map +7 -0
  15. package/build/color-ramps/lib/find-color-with-constraints.js +141 -0
  16. package/build/color-ramps/lib/find-color-with-constraints.js.map +7 -0
  17. package/build/color-ramps/lib/index.js +264 -0
  18. package/build/color-ramps/lib/index.js.map +7 -0
  19. package/build/color-ramps/lib/ramp-configs.js +315 -0
  20. package/build/color-ramps/lib/ramp-configs.js.map +7 -0
  21. package/build/color-ramps/lib/taper-chroma.js +159 -0
  22. package/build/color-ramps/lib/taper-chroma.js.map +7 -0
  23. package/build/color-ramps/lib/types.js +17 -0
  24. package/build/color-ramps/lib/types.js.map +7 -0
  25. package/build/color-ramps/lib/utils.js +106 -0
  26. package/build/color-ramps/lib/utils.js.map +7 -0
  27. package/build/context.js +34 -0
  28. package/build/context.js.map +7 -0
  29. package/build/index.js +29 -0
  30. package/build/index.js.map +7 -0
  31. package/build/lock-unlock.js +35 -0
  32. package/build/lock-unlock.js.map +7 -0
  33. package/build/prebuilt/js/design-tokens.js +135 -0
  34. package/build/prebuilt/js/design-tokens.js.map +7 -0
  35. package/build/prebuilt/json/figma.json +1317 -0
  36. package/build/prebuilt/ts/design-tokens.js +354 -0
  37. package/build/prebuilt/ts/design-tokens.js.map +7 -0
  38. package/build/private-apis.js +36 -0
  39. package/build/private-apis.js.map +7 -0
  40. package/build/style.module.css.js +2 -0
  41. package/build/theme-provider.js +92 -0
  42. package/build/theme-provider.js.map +7 -0
  43. package/build/types/css-modules.d.js +2 -0
  44. package/build/types/css-modules.d.js.map +7 -0
  45. package/build/types.js +17 -0
  46. package/build/types.js.map +7 -0
  47. package/build/use-theme-provider-styles.js +230 -0
  48. package/build/use-theme-provider-styles.js.map +7 -0
  49. package/build-module/color-ramps/index.js +95 -0
  50. package/build-module/color-ramps/index.js.map +7 -0
  51. package/build-module/color-ramps/lib/cache-utils.js +31 -0
  52. package/build-module/color-ramps/lib/cache-utils.js.map +7 -0
  53. package/build-module/color-ramps/lib/constants.js +63 -0
  54. package/build-module/color-ramps/lib/constants.js.map +7 -0
  55. package/build-module/color-ramps/lib/find-color-with-constraints.js +112 -0
  56. package/build-module/color-ramps/lib/find-color-with-constraints.js.map +7 -0
  57. package/build-module/color-ramps/lib/index.js +235 -0
  58. package/build-module/color-ramps/lib/index.js.map +7 -0
  59. package/build-module/color-ramps/lib/ramp-configs.js +290 -0
  60. package/build-module/color-ramps/lib/ramp-configs.js.map +7 -0
  61. package/build-module/color-ramps/lib/taper-chroma.js +125 -0
  62. package/build-module/color-ramps/lib/taper-chroma.js.map +7 -0
  63. package/build-module/color-ramps/lib/types.js +1 -0
  64. package/build-module/color-ramps/lib/types.js.map +7 -0
  65. package/build-module/color-ramps/lib/utils.js +84 -0
  66. package/build-module/color-ramps/lib/utils.js.map +7 -0
  67. package/build-module/context.js +10 -0
  68. package/build-module/context.js.map +7 -0
  69. package/build-module/index.js +5 -0
  70. package/build-module/index.js.map +7 -0
  71. package/build-module/lock-unlock.js +10 -0
  72. package/build-module/lock-unlock.js.map +7 -0
  73. package/build-module/prebuilt/js/design-tokens.js +115 -0
  74. package/build-module/prebuilt/js/design-tokens.js.map +7 -0
  75. package/build-module/prebuilt/json/figma.json +1317 -0
  76. package/build-module/prebuilt/ts/design-tokens.js +334 -0
  77. package/build-module/prebuilt/ts/design-tokens.js.map +7 -0
  78. package/build-module/private-apis.js +12 -0
  79. package/build-module/private-apis.js.map +7 -0
  80. package/build-module/style.module.css.js +1 -0
  81. package/build-module/theme-provider.js +58 -0
  82. package/build-module/theme-provider.js.map +7 -0
  83. package/build-module/types/css-modules.d.js +1 -0
  84. package/build-module/types/css-modules.d.js.map +7 -0
  85. package/build-module/types.js +1 -0
  86. package/build-module/types.js.map +7 -0
  87. package/build-module/use-theme-provider-styles.js +200 -0
  88. package/build-module/use-theme-provider-styles.js.map +7 -0
  89. package/build-style/style.css +3 -0
  90. package/build-types/color-ramps/index.d.ts +44 -0
  91. package/build-types/color-ramps/index.d.ts.map +1 -0
  92. package/build-types/color-ramps/lib/cache-utils.d.ts +22 -0
  93. package/build-types/color-ramps/lib/cache-utils.d.ts.map +1 -0
  94. package/build-types/color-ramps/lib/constants.d.ts +38 -0
  95. package/build-types/color-ramps/lib/constants.d.ts.map +1 -0
  96. package/build-types/color-ramps/lib/find-color-with-constraints.d.ts +37 -0
  97. package/build-types/color-ramps/lib/find-color-with-constraints.d.ts.map +1 -0
  98. package/build-types/color-ramps/lib/index.d.ts +11 -0
  99. package/build-types/color-ramps/lib/index.d.ts.map +1 -0
  100. package/build-types/color-ramps/lib/ramp-configs.d.ts +7 -0
  101. package/build-types/color-ramps/lib/ramp-configs.d.ts.map +1 -0
  102. package/build-types/color-ramps/lib/taper-chroma.d.ts +32 -0
  103. package/build-types/color-ramps/lib/taper-chroma.d.ts.map +1 -0
  104. package/build-types/color-ramps/lib/types.d.ts +78 -0
  105. package/build-types/color-ramps/lib/types.d.ts.map +1 -0
  106. package/build-types/color-ramps/lib/utils.d.ts +38 -0
  107. package/build-types/color-ramps/lib/utils.d.ts.map +1 -0
  108. package/build-types/color-ramps/stories/index.story.d.ts +14 -0
  109. package/build-types/color-ramps/stories/index.story.d.ts.map +1 -0
  110. package/build-types/color-ramps/stories/ramp-table.d.ts +19 -0
  111. package/build-types/color-ramps/stories/ramp-table.d.ts.map +1 -0
  112. package/build-types/context.d.ts +10 -0
  113. package/build-types/context.d.ts.map +1 -0
  114. package/build-types/index.d.ts +2 -0
  115. package/build-types/index.d.ts.map +1 -0
  116. package/build-types/lock-unlock.d.ts +2 -0
  117. package/build-types/lock-unlock.d.ts.map +1 -0
  118. package/build-types/prebuilt/js/design-tokens.d.ts +3 -0
  119. package/build-types/prebuilt/js/design-tokens.d.ts.map +1 -0
  120. package/build-types/prebuilt/ts/design-tokens.d.ts +7 -0
  121. package/build-types/prebuilt/ts/design-tokens.d.ts.map +1 -0
  122. package/build-types/private-apis.d.ts +2 -0
  123. package/build-types/private-apis.d.ts.map +1 -0
  124. package/build-types/stories/index.story.d.ts +15 -0
  125. package/build-types/stories/index.story.d.ts.map +1 -0
  126. package/build-types/theme-provider.d.ts +3 -0
  127. package/build-types/theme-provider.d.ts.map +1 -0
  128. package/build-types/types.d.ts +42 -0
  129. package/build-types/types.d.ts.map +1 -0
  130. package/build-types/use-theme-provider-styles.d.ts +17 -0
  131. package/build-types/use-theme-provider-styles.d.ts.map +1 -0
  132. package/docs/ds-tokens.md +283 -0
  133. package/package.json +58 -0
  134. package/src/color-ramps/index.ts +155 -0
  135. package/src/color-ramps/lib/cache-utils.ts +56 -0
  136. package/src/color-ramps/lib/constants.ts +85 -0
  137. package/src/color-ramps/lib/find-color-with-constraints.ts +190 -0
  138. package/src/color-ramps/lib/index.ts +369 -0
  139. package/src/color-ramps/lib/ramp-configs.ts +309 -0
  140. package/src/color-ramps/lib/taper-chroma.ts +226 -0
  141. package/src/color-ramps/lib/types.ts +90 -0
  142. package/src/color-ramps/lib/utils.ts +161 -0
  143. package/src/color-ramps/stories/index.story.tsx +264 -0
  144. package/src/color-ramps/stories/ramp-table.tsx +212 -0
  145. package/src/color-ramps/test/__snapshots__/index.test.ts.snap +1280 -0
  146. package/src/color-ramps/test/index.test.ts +94 -0
  147. package/src/context.ts +19 -0
  148. package/src/index.ts +2 -0
  149. package/src/lock-unlock.ts +10 -0
  150. package/src/prebuilt/css/design-tokens.css +401 -0
  151. package/src/prebuilt/js/design-tokens.js +116 -0
  152. package/src/prebuilt/json/figma.json +1317 -0
  153. package/src/prebuilt/ts/design-tokens.ts +335 -0
  154. package/src/private-apis.ts +12 -0
  155. package/src/stories/index.story.tsx +426 -0
  156. package/src/style.module.css +3 -0
  157. package/src/theme-provider.tsx +87 -0
  158. package/src/types/css-modules.d.ts +4 -0
  159. package/src/types.ts +44 -0
  160. package/src/use-theme-provider-styles.ts +247 -0
  161. package/terrazzo.config.ts +102 -0
  162. package/tokens/border.json +34 -0
  163. package/tokens/color.json +877 -0
  164. package/tokens/elevation.json +201 -0
  165. package/tokens/spacing.json +45 -0
  166. package/tokens/typography.json +93 -0
  167. package/tsconfig.json +9 -0
  168. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,56 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import type Color from 'colorjs.io';
5
+
6
+ /**
7
+ * Cache for WCAG contrast calculations
8
+ */
9
+ const contrastCache = new Map< string, number >();
10
+
11
+ /**
12
+ * Cache for color string representations
13
+ */
14
+ const colorStringCache = new Map< Color, string >();
15
+
16
+ /**
17
+ * Get cached string representation of a color
18
+ * @param color - Color object to stringify
19
+ * @return Cached string representation
20
+ */
21
+ export function getColorString( color: Color ): string {
22
+ let str = colorStringCache.get( color );
23
+ if ( str === undefined ) {
24
+ str = color.to( 'srgb' ).toString( { format: 'hex', inGamut: true } );
25
+ colorStringCache.set( color, str );
26
+ }
27
+ return str;
28
+ }
29
+
30
+ /**
31
+ * Get cached contrast calculation between two colors
32
+ * @param colorA - First color
33
+ * @param colorB - Second color
34
+ * @return WCAG 2.1 contrast ratio
35
+ */
36
+ export function getCachedContrast( colorA: Color, colorB: Color ): number {
37
+ const keyA = getColorString( colorA );
38
+ const keyB = getColorString( colorB );
39
+ const cacheKey =
40
+ keyA < keyB ? `${ keyA }|${ keyB }` : `${ keyB }|${ keyA }`;
41
+
42
+ let contrast = contrastCache.get( cacheKey );
43
+ if ( contrast === undefined ) {
44
+ contrast = colorA.contrastWCAG21( colorB );
45
+ contrastCache.set( cacheKey, contrast );
46
+ }
47
+ return contrast;
48
+ }
49
+
50
+ /**
51
+ * Clear all caches - useful for memory management or testing
52
+ */
53
+ export function clearCaches(): void {
54
+ contrastCache.clear();
55
+ colorStringCache.clear();
56
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import Color from 'colorjs.io';
5
+
6
+ /**
7
+ * Internal dependencies
8
+ */
9
+ import type { Ramp } from './types';
10
+
11
+ export const WHITE = new Color( '#fff' ).to( 'oklch' );
12
+ export const BLACK = new Color( '#000' ).to( 'oklch' );
13
+
14
+ // Margin added to target contrasts to counter for algorithm approximations
15
+ // and rounding errors.
16
+ export const UNIVERSAL_CONTRAST_TOPUP = 0.05;
17
+
18
+ // When enabling "lighter direction" bias, this is the amount by which
19
+ // black text contrast needs to be greater than white text contrast.
20
+ // The higher the value, the stronger the preference for white text.
21
+ // The current value has been determined empirically as the highest value
22
+ // that won't cause the algo not to be able to correctly solve all contrasts.
23
+ export const WHITE_TEXT_CONTRAST_MARGIN = 3.1;
24
+
25
+ // These values are used as thresholds when trying to match the background
26
+ // ramp's lightness while calculating an accent ramp. They prevent the accent
27
+ // scale from being pinned to lightness values in the middle of the range,
28
+ // which would cause the algorithm to struggle to satisfy the accent scale
29
+ // constraints and therefore produce unexpected results.
30
+ export const ACCENT_SCALE_BASE_LIGHTNESS_THRESHOLDS = {
31
+ lighter: { min: 0.2, max: 0.4 },
32
+ darker: { min: 0.75, max: 0.98 },
33
+ } as const;
34
+
35
+ // Minimum lightness difference noticed by the algorithm.
36
+ export const LIGHTNESS_EPSILON = 1e-3;
37
+
38
+ export const MAX_BISECTION_ITERATIONS = 25;
39
+
40
+ export const CONTRAST_COMBINATIONS: {
41
+ bgs: ( keyof Ramp )[];
42
+ fgs: ( keyof Ramp )[];
43
+ target: number;
44
+ }[] = [
45
+ {
46
+ bgs: [ 'surface1', 'surface2', 'surface3' ],
47
+ fgs: [ 'fgSurface3', 'fgSurface4' ],
48
+ target: 4.5,
49
+ },
50
+ {
51
+ bgs: [ 'surface4', 'surface5' ],
52
+ fgs: [ 'fgSurface4' ],
53
+ target: 4.5,
54
+ },
55
+ {
56
+ bgs: [ 'bgFill1' ],
57
+ fgs: [ 'fgFill' ],
58
+ target: 4.5,
59
+ },
60
+ {
61
+ bgs: [ 'bgFillInverted1' ],
62
+ fgs: [ 'fgFillInverted' ],
63
+ target: 4.5,
64
+ },
65
+ {
66
+ bgs: [ 'bgFillInverted1' ],
67
+ fgs: [ 'fgFillInverted' ],
68
+ target: 4.5,
69
+ },
70
+ {
71
+ bgs: [ 'surface1', 'surface2', 'surface3' ],
72
+ fgs: [ 'stroke3' ],
73
+ target: 3,
74
+ },
75
+ ];
76
+
77
+ // Used when generating the DTCG tokens and the static color ramps.
78
+ export const DEFAULT_SEED_COLORS = {
79
+ bg: '#f8f8f8',
80
+ primary: '#3858e9',
81
+ info: '#0090ff',
82
+ success: '#4ab866',
83
+ warning: '#f0b849',
84
+ error: '#cc1818',
85
+ };
@@ -0,0 +1,190 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import Color from 'colorjs.io';
5
+
6
+ /**
7
+ * Internal dependencies
8
+ */
9
+ import { clampToGamut } from './utils';
10
+ import {
11
+ WHITE,
12
+ BLACK,
13
+ LIGHTNESS_EPSILON,
14
+ MAX_BISECTION_ITERATIONS,
15
+ } from './constants';
16
+ import { getCachedContrast } from './cache-utils';
17
+ import { type TaperChromaOptions, taperChroma } from './taper-chroma';
18
+
19
+ /**
20
+ * Solve for L such that:
21
+ * - the L applied to the seed meets the contrast target against the reference
22
+ * - the search is performed in one direction (ie lighter / darker)
23
+ * - more constraints can be applied around lightness
24
+ * - chroma could be tapered
25
+ * @param reference
26
+ * @param seed
27
+ * @param target
28
+ * @param direction
29
+ * @param options
30
+ * @param options.strict
31
+ * @param options.debug
32
+ * @param options.lightnessConstraint
33
+ * @param options.lightnessConstraint.type
34
+ * @param options.lightnessConstraint.value
35
+ * @param options.taperChromaOptions
36
+ */
37
+ export function findColorMeetingRequirements(
38
+ reference: Color,
39
+ seed: Color,
40
+ target: number,
41
+ direction: 'lighter' | 'darker',
42
+ {
43
+ lightnessConstraint,
44
+ taperChromaOptions,
45
+ strict = true,
46
+ debug = false,
47
+ }: {
48
+ lightnessConstraint?: {
49
+ type: 'force' | 'onlyIfSucceeds';
50
+ value: number;
51
+ };
52
+ taperChromaOptions?: TaperChromaOptions;
53
+ strict?: boolean;
54
+ debug?: boolean;
55
+ } = {}
56
+ ): { color: Color; reached: boolean; achieved: number } {
57
+ // A target of 1 means same color.
58
+ // A target lower than 1 doesn't make sense.
59
+ if ( target <= 1 ) {
60
+ return { color: seed.clone(), reached: true, achieved: 1 };
61
+ }
62
+
63
+ if ( lightnessConstraint ) {
64
+ // Apply a specific L value.
65
+ // Useful when pinning a step to a specific lightness, of to specify
66
+ // min/max L values.
67
+ let newL = lightnessConstraint.value;
68
+ let newC = seed.oklch.c;
69
+
70
+ if ( taperChromaOptions ) {
71
+ ( { l: newL, c: newC } = taperChroma(
72
+ seed,
73
+ newL,
74
+ taperChromaOptions
75
+ ) );
76
+ }
77
+
78
+ const colorWithExactL = clampToGamut(
79
+ new Color( 'oklch', [ newL, newC, seed.oklch.h ] )
80
+ );
81
+ const exactLContrast = getCachedContrast( reference, colorWithExactL );
82
+
83
+ if ( debug ) {
84
+ // eslint-disable-next-line no-console
85
+ console.log(
86
+ `Succeeded with ${ lightnessConstraint.type } lightness`,
87
+ lightnessConstraint.value,
88
+ colorWithExactL.oklch.l
89
+ );
90
+ }
91
+
92
+ // If the L constraint is of "force" type, apply it even when it doesn't
93
+ // meet the contrast target.
94
+ if (
95
+ lightnessConstraint.type === 'force' ||
96
+ exactLContrast >= target
97
+ ) {
98
+ return {
99
+ color: colorWithExactL,
100
+ reached: exactLContrast >= target,
101
+ achieved: exactLContrast,
102
+ };
103
+ }
104
+ }
105
+
106
+ // Set the boundary based on the direction.
107
+ const mostContrastingL = direction === 'lighter' ? 1 : 0;
108
+ const mostContrastingColor = direction === 'lighter' ? WHITE : BLACK;
109
+ const highestPossibleContrast = getCachedContrast(
110
+ reference,
111
+ mostContrastingColor
112
+ );
113
+
114
+ // If even the most contrasting color can't reach the target,
115
+ // the target is unreachable.
116
+ if ( highestPossibleContrast < target ) {
117
+ if ( strict ) {
118
+ throw new Error(
119
+ `Contrast target ${ target.toFixed(
120
+ 2
121
+ ) }:1 unreachable in ${ direction } direction against ${ mostContrastingColor.toString() }` +
122
+ `(boundary achieves ${ highestPossibleContrast.toFixed(
123
+ 3
124
+ ) }:1).`
125
+ );
126
+ }
127
+
128
+ if ( debug ) {
129
+ // eslint-disable-next-line no-console
130
+ console.log(
131
+ 'Did not succeeded because it reached the limit',
132
+ mostContrastingL
133
+ );
134
+ }
135
+ return {
136
+ color: mostContrastingColor,
137
+ reached: false,
138
+ achieved: highestPossibleContrast,
139
+ };
140
+ }
141
+
142
+ // Bracket: low fails, high meets.
143
+ // Originally this was seed.oklch.l — although it's an assumption that works
144
+ // only when we know for sure the direction of the search.
145
+ // TODO: can we bring this back to seed.oklch.l ?
146
+ let worseL = reference.oklch.l;
147
+ let betterL = mostContrastingL;
148
+
149
+ let bestContrastFound = highestPossibleContrast;
150
+ let resultingColor = mostContrastingColor;
151
+
152
+ for (
153
+ let i = 0;
154
+ i < MAX_BISECTION_ITERATIONS &&
155
+ Math.abs( betterL - worseL ) > LIGHTNESS_EPSILON;
156
+ i++
157
+ ) {
158
+ let newL = ( worseL + betterL ) / 2;
159
+ let newC = seed.oklch.c;
160
+
161
+ if ( taperChromaOptions ) {
162
+ ( { l: newL, c: newC } = taperChroma(
163
+ seed,
164
+ newL,
165
+ taperChromaOptions
166
+ ) );
167
+ }
168
+
169
+ const newColor = clampToGamut(
170
+ new Color( 'oklch', [ newL, newC, seed.oklch.h ] )
171
+ );
172
+ const newContrast = getCachedContrast( reference, newColor );
173
+
174
+ if ( newContrast >= target ) {
175
+ betterL = newL;
176
+ // Only update the resulting color when the target is met, this ensuring
177
+ // at the end of the search the target is always met.
178
+ bestContrastFound = newContrast;
179
+ resultingColor = newColor;
180
+ } else {
181
+ worseL = newL;
182
+ }
183
+ }
184
+
185
+ return {
186
+ color: resultingColor,
187
+ reached: true,
188
+ achieved: bestContrastFound,
189
+ };
190
+ }
@@ -0,0 +1,369 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import Color from 'colorjs.io';
5
+
6
+ /**
7
+ * Internal dependencies
8
+ */
9
+ import { getCachedContrast, getColorString } from './cache-utils';
10
+ import { findColorMeetingRequirements } from './find-color-with-constraints';
11
+ import {
12
+ clampToGamut,
13
+ sortByDependency,
14
+ computeBetterFgColorDirection,
15
+ adjustContrastTarget,
16
+ } from './utils';
17
+
18
+ import type {
19
+ FollowDirection,
20
+ Ramp,
21
+ RampDirection,
22
+ RampConfig,
23
+ RampResult,
24
+ } from './types';
25
+ import { LIGHTNESS_EPSILON, MAX_BISECTION_ITERATIONS } from './constants';
26
+
27
+ /**
28
+ * Calculate a complete color ramp based on the provided configuration.
29
+ *
30
+ * @param params - The calculation parameters
31
+ * @param params.seed - The base color to build the ramp from
32
+ * @param params.sortedSteps - Steps sorted in dependency order
33
+ * @param params.config - Ramp configuration defining contrast requirements
34
+ * @param params.mainDir - Primary direction for the ramp (lighter/darker)
35
+ * @param params.oppDir - Opposite direction from mainDir
36
+ * @param params.pinLightness - Optional lightness override for a given step
37
+ * @param params.pinLightness.stepName
38
+ * @param params.pinLightness.value
39
+ * @param params.debug
40
+ * @return Object containing ramp results and satisfaction status
41
+ */
42
+ function calculateRamp( {
43
+ seed,
44
+ sortedSteps,
45
+ config,
46
+ mainDir,
47
+ oppDir,
48
+ pinLightness,
49
+ debug = false,
50
+ }: {
51
+ seed: Color;
52
+ sortedSteps: ( keyof Ramp )[];
53
+ config: RampConfig;
54
+ mainDir: RampDirection;
55
+ oppDir: RampDirection;
56
+ pinLightness?: {
57
+ stepName: keyof Ramp;
58
+ value: number;
59
+ };
60
+ debug?: boolean;
61
+ } ) {
62
+ const rampResults = {} as Record<
63
+ keyof Ramp,
64
+ { color: string; warning: boolean }
65
+ >;
66
+ let SATISFIED_ALL_CONTRAST_REQUIREMENTS = true;
67
+ let UNSATISFIED_DIRECTION: RampDirection = 'lighter';
68
+ let MAX_WEIGHTED_DEFICIT = 0;
69
+
70
+ // Keep track of the calculated colors, as they are going to be useful
71
+ // when other colors reference them.
72
+ const calculatedColors = new Map< keyof Ramp | 'seed', Color >();
73
+ calculatedColors.set( 'seed', seed );
74
+
75
+ for ( const stepName of sortedSteps ) {
76
+ const {
77
+ contrast,
78
+ lightness: stepLightnessConstraint,
79
+ taperChromaOptions,
80
+ sameAsIfPossible,
81
+ } = config[ stepName ];
82
+ const referenceColor = calculatedColors.get( contrast.reference );
83
+
84
+ if ( ! referenceColor ) {
85
+ throw new Error(
86
+ `Reference color for step ${ stepName } not found: ${ contrast.reference }`
87
+ );
88
+ }
89
+
90
+ // Check if we can reuse color from the `sameAsIfPossible` config option
91
+ if ( sameAsIfPossible ) {
92
+ const candidateColor = calculatedColors.get( sameAsIfPossible );
93
+ if ( candidateColor ) {
94
+ const candidateContrast = getCachedContrast(
95
+ referenceColor,
96
+ candidateColor
97
+ );
98
+ const adjustedTarget = adjustContrastTarget( contrast.target );
99
+ // If the candidate meets the contrast requirement, use it
100
+ if ( candidateContrast >= adjustedTarget ) {
101
+ // Store the reused color
102
+ calculatedColors.set( stepName, candidateColor );
103
+ rampResults[ stepName ] = {
104
+ color: getColorString( candidateColor ),
105
+ warning: false,
106
+ };
107
+
108
+ continue; // Skip to next step
109
+ }
110
+ }
111
+ }
112
+
113
+ function computeDirection(
114
+ color: Color,
115
+ followDirection: FollowDirection
116
+ ): RampDirection {
117
+ if ( followDirection === 'main' ) {
118
+ return mainDir;
119
+ }
120
+
121
+ if ( followDirection === 'opposite' ) {
122
+ return oppDir;
123
+ }
124
+
125
+ if ( followDirection === 'best' ) {
126
+ return computeBetterFgColorDirection(
127
+ color,
128
+ contrast.preferLighter
129
+ ).better;
130
+ }
131
+
132
+ return followDirection;
133
+ }
134
+
135
+ const computedDir = computeDirection(
136
+ referenceColor,
137
+ contrast.followDirection
138
+ );
139
+
140
+ const adjustedTarget = adjustContrastTarget( contrast.target );
141
+
142
+ // Define the lightness constraint, if needed.
143
+ let lightnessConstraint;
144
+ if ( pinLightness?.stepName === stepName ) {
145
+ lightnessConstraint = {
146
+ value: pinLightness.value,
147
+ type: 'force',
148
+ } as const;
149
+ } else if ( stepLightnessConstraint ) {
150
+ lightnessConstraint = {
151
+ value: stepLightnessConstraint( computedDir ),
152
+ type: 'onlyIfSucceeds',
153
+ } as const;
154
+ }
155
+
156
+ // Calculate the color meeting the requirements
157
+ const searchResults = findColorMeetingRequirements(
158
+ referenceColor,
159
+ seed,
160
+ adjustedTarget,
161
+ computedDir,
162
+ {
163
+ strict: false,
164
+ lightnessConstraint,
165
+ taperChromaOptions,
166
+ debug,
167
+ }
168
+ );
169
+
170
+ // When the target contrast is not met, take note of it and use
171
+ // that information to guide the ramp calculation bisection.
172
+ if ( ! searchResults.reached && ! contrast.ignoreWhenAdjustingSeed ) {
173
+ SATISFIED_ALL_CONTRAST_REQUIREMENTS = false;
174
+
175
+ // Calculate constraint failure severity for seed optimization
176
+ // Use the relative deficit size, weighted by how changing the seed would impact this constraint
177
+ const deficitVsTarget = adjustedTarget - searchResults.achieved;
178
+
179
+ // Weight the deficit by how much seed adjustment would help this constraint
180
+ // If seed has low contrast vs reference, adjusting seed has high impact
181
+ // If seed has high contrast vs reference, adjusting seed has low impact
182
+ const impactWeight = 1 / getCachedContrast( seed, referenceColor );
183
+ const weightedDeficit = deficitVsTarget * impactWeight;
184
+
185
+ // Track the most impactful failure for seed optimization
186
+ if ( weightedDeficit > MAX_WEIGHTED_DEFICIT ) {
187
+ MAX_WEIGHTED_DEFICIT = weightedDeficit;
188
+ UNSATISFIED_DIRECTION = computedDir;
189
+ }
190
+ }
191
+
192
+ // Store calculated color for future dependencies
193
+ calculatedColors.set( stepName, searchResults.color );
194
+
195
+ // Add to results
196
+ rampResults[ stepName ] = {
197
+ color: getColorString( searchResults.color ),
198
+ warning:
199
+ ! contrast.ignoreWhenAdjustingSeed && ! searchResults.reached,
200
+ };
201
+ }
202
+
203
+ return {
204
+ rampResults,
205
+ SATISFIED_ALL_CONTRAST_REQUIREMENTS,
206
+ UNSATISFIED_DIRECTION,
207
+ };
208
+ }
209
+
210
+ export function buildRamp(
211
+ seedArg: string,
212
+ config: RampConfig,
213
+ {
214
+ mainDirection,
215
+ pinLightness,
216
+ debug = false,
217
+ rescaleToFitContrastTargets = true,
218
+ }: {
219
+ mainDirection?: RampDirection;
220
+ pinLightness?: {
221
+ stepName: keyof Ramp;
222
+ value: number;
223
+ };
224
+ rescaleToFitContrastTargets?: boolean;
225
+ debug?: boolean;
226
+ } = {}
227
+ ): RampResult {
228
+ let seed: Color;
229
+ try {
230
+ seed = clampToGamut( new Color( seedArg ) );
231
+ } catch ( error ) {
232
+ throw new Error(
233
+ `Invalid seed color "${ seedArg }": ${
234
+ error instanceof Error ? error.message : 'Unknown error'
235
+ }`
236
+ );
237
+ }
238
+
239
+ let mainDir: RampDirection = 'lighter';
240
+ let oppDir: RampDirection = 'darker';
241
+
242
+ if ( mainDirection ) {
243
+ mainDir = mainDirection;
244
+ oppDir = mainDirection === 'darker' ? 'lighter' : 'darker';
245
+ } else {
246
+ const { better, worse } = computeBetterFgColorDirection( seed );
247
+ mainDir = better;
248
+ oppDir = worse;
249
+ }
250
+
251
+ // Get the correct calculation order based on dependencies
252
+ const sortedSteps = sortByDependency( config );
253
+
254
+ // Calculate the ramp with the initial seed.
255
+ const {
256
+ rampResults,
257
+ SATISFIED_ALL_CONTRAST_REQUIREMENTS,
258
+ UNSATISFIED_DIRECTION,
259
+ } = calculateRamp( {
260
+ seed,
261
+ sortedSteps,
262
+ config,
263
+ mainDir,
264
+ oppDir,
265
+ pinLightness,
266
+ debug,
267
+ } );
268
+ const toReturn = {
269
+ ramp: rampResults,
270
+ direction: mainDir,
271
+ } as RampResult;
272
+
273
+ if ( debug ) {
274
+ // eslint-disable-next-line no-console
275
+ console.log( `First run`, {
276
+ SATISFIED_ALL_CONTRAST_REQUIREMENTS,
277
+ UNSATISFIED_DIRECTION,
278
+ seed: seed.toString(),
279
+ sortedSteps,
280
+ config,
281
+ mainDir,
282
+ oppDir,
283
+ pinLightness,
284
+ } );
285
+ }
286
+
287
+ if (
288
+ ! SATISFIED_ALL_CONTRAST_REQUIREMENTS &&
289
+ rescaleToFitContrastTargets
290
+ ) {
291
+ let worseSeedL = seed.oklch.l;
292
+ // For a scale with the "lighter" direction, the contrast can be improved
293
+ // by darkening the seed. For "darker" direction, by lightening the seed.
294
+ let betterSeedL = UNSATISFIED_DIRECTION === 'lighter' ? 0 : 1;
295
+
296
+ // Binary search: try a new seed and recompute the whole ramp
297
+ // (TODO: try a smarter approach?)
298
+ for (
299
+ let i = 0;
300
+ i < MAX_BISECTION_ITERATIONS &&
301
+ Math.abs( betterSeedL - worseSeedL ) > LIGHTNESS_EPSILON;
302
+ i++
303
+ ) {
304
+ const newSeed = clampToGamut(
305
+ seed.clone().set( {
306
+ l: ( worseSeedL + betterSeedL ) / 2,
307
+ } )
308
+ );
309
+
310
+ if ( debug ) {
311
+ // eslint-disable-next-line no-console
312
+ console.log( `Iteration ${ i }`, {
313
+ worseSeedL,
314
+ newSeedL: ( worseSeedL + betterSeedL ) / 2,
315
+ betterSeedL,
316
+ } );
317
+ }
318
+
319
+ const iterationResults = calculateRamp( {
320
+ seed: newSeed,
321
+ sortedSteps,
322
+ config,
323
+ mainDir,
324
+ oppDir,
325
+ pinLightness,
326
+ debug,
327
+ } );
328
+
329
+ if ( iterationResults.SATISFIED_ALL_CONTRAST_REQUIREMENTS ) {
330
+ betterSeedL = newSeed.oklch.l;
331
+ // Only update toReturn when the ramp satisfies all constraints.
332
+ toReturn.ramp = iterationResults.rampResults;
333
+ } else if ( UNSATISFIED_DIRECTION !== mainDir ) {
334
+ // Failing constraint is in opposite direction to main ramp direction
335
+ // We've moved too far in mainDir, constrain the search
336
+ betterSeedL = newSeed.oklch.l;
337
+ } else {
338
+ // Failing constraint is in same direction as main ramp direction
339
+ // We haven't moved far enough in mainDir, continue searching
340
+ worseSeedL = newSeed.oklch.l;
341
+ }
342
+
343
+ if ( debug ) {
344
+ // eslint-disable-next-line no-console
345
+ console.log( `Retry #${ i }`, {
346
+ SATISFIED_ALL_CONTRAST_REQUIREMENTS,
347
+ UNSATISFIED_DIRECTION,
348
+ seed: newSeed.toString(),
349
+ sortedSteps,
350
+ config,
351
+ mainDir,
352
+ oppDir,
353
+ pinLightness,
354
+ } );
355
+ }
356
+ }
357
+ }
358
+
359
+ // Swap surface1 and surface3 for darker ramps to maintain visual elevation hierarchy.
360
+ // This ensures surface1 appears "behind" surface2, and surface3 appears "in front",
361
+ // regardless of the ramp's main direction.
362
+ if ( mainDir === 'darker' ) {
363
+ const tmpSurface1 = toReturn.ramp.surface1;
364
+ toReturn.ramp.surface1 = toReturn.ramp.surface3;
365
+ toReturn.ramp.surface3 = tmpSurface1;
366
+ }
367
+
368
+ return toReturn;
369
+ }