@wordpress/theme 0.1.1-next.2f1c7c01b.0 → 0.2.1-next.16d95556a.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 (106) hide show
  1. package/README.md +5 -1
  2. package/bin/generate-primitive-tokens/index.ts +1 -1
  3. package/bin/terrazzo-plugin-ds-tokens-docs/index.ts +5 -24
  4. package/bin/terrazzo-plugin-inline-alias-values/index.ts +84 -0
  5. package/bin/terrazzo-plugin-known-wpds-css-variables/index.ts +19 -39
  6. package/build/color-ramps/index.js +5 -5
  7. package/build/color-ramps/index.js.map +2 -2
  8. package/build/color-ramps/lib/constants.js +4 -4
  9. package/build/color-ramps/lib/constants.js.map +2 -2
  10. package/build/color-ramps/lib/default-ramps.js +154 -154
  11. package/build/color-ramps/lib/default-ramps.js.map +2 -2
  12. package/build/color-ramps/lib/find-color-with-constraints.js +36 -53
  13. package/build/color-ramps/lib/find-color-with-constraints.js.map +2 -2
  14. package/build/color-ramps/lib/index.js +72 -64
  15. package/build/color-ramps/lib/index.js.map +2 -2
  16. package/build/color-ramps/lib/ramp-configs.js +3 -3
  17. package/build/color-ramps/lib/ramp-configs.js.map +1 -1
  18. package/build/color-ramps/lib/types.js.map +1 -1
  19. package/build/color-ramps/lib/utils.js +63 -2
  20. package/build/color-ramps/lib/utils.js.map +2 -2
  21. package/build/prebuilt/js/design-tokens.js +5 -10
  22. package/build/prebuilt/js/design-tokens.js.map +2 -2
  23. package/build/prebuilt/json/figma.json +105 -905
  24. package/build/prebuilt/ts/color-tokens.js +137 -0
  25. package/build/prebuilt/ts/color-tokens.js.map +7 -0
  26. package/build/token-id.js +30 -0
  27. package/build/token-id.js.map +7 -0
  28. package/build/use-theme-provider-styles.js +15 -27
  29. package/build/use-theme-provider-styles.js.map +3 -3
  30. package/build-module/color-ramps/index.js +5 -5
  31. package/build-module/color-ramps/index.js.map +2 -2
  32. package/build-module/color-ramps/lib/constants.js +3 -3
  33. package/build-module/color-ramps/lib/constants.js.map +2 -2
  34. package/build-module/color-ramps/lib/default-ramps.js +154 -154
  35. package/build-module/color-ramps/lib/default-ramps.js.map +2 -2
  36. package/build-module/color-ramps/lib/find-color-with-constraints.js +38 -60
  37. package/build-module/color-ramps/lib/find-color-with-constraints.js.map +2 -2
  38. package/build-module/color-ramps/lib/index.js +76 -66
  39. package/build-module/color-ramps/lib/index.js.map +2 -2
  40. package/build-module/color-ramps/lib/ramp-configs.js +3 -3
  41. package/build-module/color-ramps/lib/ramp-configs.js.map +1 -1
  42. package/build-module/color-ramps/lib/utils.js +63 -2
  43. package/build-module/color-ramps/lib/utils.js.map +2 -2
  44. package/build-module/prebuilt/js/design-tokens.js +5 -10
  45. package/build-module/prebuilt/js/design-tokens.js.map +2 -2
  46. package/build-module/prebuilt/json/figma.json +105 -905
  47. package/build-module/prebuilt/ts/color-tokens.js +117 -0
  48. package/build-module/prebuilt/ts/color-tokens.js.map +7 -0
  49. package/build-module/token-id.js +6 -0
  50. package/build-module/token-id.js.map +7 -0
  51. package/build-module/use-theme-provider-styles.js +15 -27
  52. package/build-module/use-theme-provider-styles.js.map +2 -2
  53. package/build-types/color-ramps/lib/constants.d.ts +2 -2
  54. package/build-types/color-ramps/lib/constants.d.ts.map +1 -1
  55. package/build-types/color-ramps/lib/find-color-with-constraints.d.ts +2 -3
  56. package/build-types/color-ramps/lib/find-color-with-constraints.d.ts.map +1 -1
  57. package/build-types/color-ramps/lib/index.d.ts.map +1 -1
  58. package/build-types/color-ramps/lib/types.d.ts +2 -4
  59. package/build-types/color-ramps/lib/types.d.ts.map +1 -1
  60. package/build-types/color-ramps/lib/utils.d.ts +21 -2
  61. package/build-types/color-ramps/lib/utils.d.ts.map +1 -1
  62. package/build-types/color-ramps/stories/index.story.d.ts.map +1 -1
  63. package/build-types/color-ramps/stories/ramp-table.d.ts +2 -4
  64. package/build-types/color-ramps/stories/ramp-table.d.ts.map +1 -1
  65. package/build-types/prebuilt/ts/color-tokens.d.ts +7 -0
  66. package/build-types/prebuilt/ts/color-tokens.d.ts.map +1 -0
  67. package/build-types/stories/index.story.d.ts.map +1 -1
  68. package/build-types/theme-provider.d.ts.map +1 -1
  69. package/build-types/token-id.d.ts +9 -0
  70. package/build-types/token-id.d.ts.map +1 -0
  71. package/build-types/use-theme-provider-styles.d.ts.map +1 -1
  72. package/docs/ds-tokens.md +10 -178
  73. package/package.json +4 -4
  74. package/src/color-ramps/index.ts +5 -5
  75. package/src/color-ramps/lib/constants.ts +7 -5
  76. package/src/color-ramps/lib/default-ramps.ts +154 -154
  77. package/src/color-ramps/lib/find-color-with-constraints.ts +53 -77
  78. package/src/color-ramps/lib/index.ts +100 -100
  79. package/src/color-ramps/lib/ramp-configs.ts +3 -3
  80. package/src/color-ramps/lib/types.ts +2 -7
  81. package/src/color-ramps/lib/utils.ts +109 -5
  82. package/src/color-ramps/stories/index.story.tsx +4 -1
  83. package/src/color-ramps/stories/ramp-table.tsx +15 -26
  84. package/src/color-ramps/test/__snapshots__/index.test.ts.snap +16891 -1059
  85. package/src/color-ramps/test/index.test.ts +43 -16
  86. package/src/prebuilt/css/design-tokens.css +88 -413
  87. package/src/prebuilt/js/design-tokens.js +5 -10
  88. package/src/prebuilt/json/figma.json +105 -905
  89. package/src/prebuilt/ts/color-tokens.ts +117 -0
  90. package/src/stories/index.story.tsx +4 -18
  91. package/src/test/token-id.test.ts +12 -0
  92. package/src/token-id.ts +9 -0
  93. package/src/use-theme-provider-styles.ts +17 -35
  94. package/terrazzo.config.ts +15 -12
  95. package/tokens/color.json +82 -82
  96. package/tokens/dimension.json +75 -0
  97. package/tsconfig.bin.tsbuildinfo +1 -1
  98. package/tsconfig.src.tsbuildinfo +1 -1
  99. package/build/prebuilt/ts/design-tokens.js +0 -391
  100. package/build/prebuilt/ts/design-tokens.js.map +0 -7
  101. package/build-module/prebuilt/ts/design-tokens.js +0 -371
  102. package/build-module/prebuilt/ts/design-tokens.js.map +0 -7
  103. package/build-types/prebuilt/ts/design-tokens.d.ts +0 -7
  104. package/build-types/prebuilt/ts/design-tokens.d.ts.map +0 -1
  105. package/src/prebuilt/ts/design-tokens.ts +0 -371
  106. package/tokens/spacing.json +0 -45
@@ -7,16 +7,22 @@ import { get, OKLCH, type ColorTypes } from 'colorjs.io/fn';
7
7
  * Internal dependencies
8
8
  */
9
9
  import './register-color-spaces';
10
- import { clampToGamut } from './utils';
11
- import {
12
- WHITE,
13
- BLACK,
14
- LIGHTNESS_EPSILON,
15
- MAX_BISECTION_ITERATIONS,
16
- } from './constants';
10
+ import { clampToGamut, solveWithBisect } from './utils';
11
+ import { WHITE, BLACK, CONTRAST_EPSILON } from './constants';
17
12
  import { getContrast } from './color-utils';
18
13
  import { type TaperChromaOptions, taperChroma } from './taper-chroma';
19
14
 
15
+ /**
16
+ * Difference of contrast values that grows linearly with the Y luminance.
17
+ * We get more precise linear interpolations when we use this.
18
+ * @param c1 First contrast.
19
+ * @param c2 Second contrast.
20
+ * @return Difference of logarithms.
21
+ */
22
+ function cdiff( c1: number, c2: number ) {
23
+ return Math.log( c1 / c2 );
24
+ }
25
+
20
26
  /**
21
27
  * Solve for L such that:
22
28
  * - the L applied to the seed meets the contrast target against the reference
@@ -28,7 +34,6 @@ import { type TaperChromaOptions, taperChroma } from './taper-chroma';
28
34
  * @param target
29
35
  * @param direction
30
36
  * @param options
31
- * @param options.strict
32
37
  * @param options.lightnessConstraint
33
38
  * @param options.lightnessConstraint.type
34
39
  * @param options.lightnessConstraint.value
@@ -42,20 +47,22 @@ export function findColorMeetingRequirements(
42
47
  {
43
48
  lightnessConstraint,
44
49
  taperChromaOptions,
45
- strict = true,
46
50
  }: {
47
51
  lightnessConstraint?: {
48
52
  type: 'force' | 'onlyIfSucceeds';
49
53
  value: number;
50
54
  };
51
55
  taperChromaOptions?: TaperChromaOptions;
52
- strict?: boolean;
53
56
  } = {}
54
- ): { color: ColorTypes; reached: boolean; achieved: number } {
57
+ ): { color: ColorTypes; reached: boolean; achieved: number; deficit?: number } {
55
58
  // A target of 1 means same color.
56
59
  // A target lower than 1 doesn't make sense.
57
60
  if ( target <= 1 ) {
58
- return { color: seed, reached: true, achieved: 1 };
61
+ return {
62
+ color: reference,
63
+ reached: true,
64
+ achieved: 1,
65
+ };
59
66
  }
60
67
 
61
68
  function getColorForL( l: number ): ColorTypes {
@@ -80,48 +87,45 @@ export function findColorMeetingRequirements(
80
87
  } );
81
88
  }
82
89
 
90
+ // Set the boundary based on the direction.
91
+ const mostContrastingL = direction === 'lighter' ? 1 : 0;
92
+ const mostContrastingColor = direction === 'lighter' ? WHITE : BLACK;
93
+ const highestContrast = getContrast( reference, mostContrastingColor );
94
+
83
95
  if ( lightnessConstraint ) {
84
96
  // Apply a specific L value.
85
97
  // Useful when pinning a step to a specific lightness, of to specify
86
98
  // min/max L values.
87
99
  const colorWithExactL = getColorForL( lightnessConstraint.value );
88
100
  const exactLContrast = getContrast( reference, colorWithExactL );
101
+ const exactLContrastMeetsTarget =
102
+ cdiff( exactLContrast, target ) >= -CONTRAST_EPSILON;
89
103
 
90
104
  // If the L constraint is of "force" type, apply it even when it doesn't
91
105
  // meet the contrast target.
92
106
  if (
93
- lightnessConstraint.type === 'force' ||
94
- exactLContrast >= target
107
+ exactLContrastMeetsTarget ||
108
+ lightnessConstraint.type === 'force'
95
109
  ) {
96
110
  return {
97
111
  color: colorWithExactL,
98
- reached: exactLContrast >= target,
112
+ reached: exactLContrastMeetsTarget,
99
113
  achieved: exactLContrast,
114
+ deficit: exactLContrastMeetsTarget
115
+ ? cdiff( exactLContrast, highestContrast )
116
+ : cdiff( target, exactLContrast ),
100
117
  };
101
118
  }
102
119
  }
103
120
 
104
- // Set the boundary based on the direction.
105
- const mostContrastingL = direction === 'lighter' ? 1 : 0;
106
- const mostContrastingColor = direction === 'lighter' ? WHITE : BLACK;
107
- const highestContrast = getContrast( reference, mostContrastingColor );
108
-
109
- // If even the most contrasting color can't reach the target,
110
- // the target is unreachable.
111
- if ( highestContrast < target ) {
112
- if ( strict ) {
113
- throw new Error(
114
- `Contrast target ${ target.toFixed(
115
- 2
116
- ) }:1 unreachable in ${ direction } direction` +
117
- `(boundary achieves ${ highestContrast.toFixed( 3 ) }:1).`
118
- );
119
- }
120
-
121
+ // If even the most contrasting color can't reach the target, the target is unreachable.
122
+ // On the other hand, if the contrast is very close to the target, we consider it reached.
123
+ if ( cdiff( highestContrast, target ) <= CONTRAST_EPSILON ) {
121
124
  return {
122
125
  color: mostContrastingColor,
123
- reached: false,
126
+ reached: cdiff( highestContrast, target ) >= -CONTRAST_EPSILON,
124
127
  achieved: highestContrast,
128
+ deficit: cdiff( target, highestContrast ),
125
129
  };
126
130
  }
127
131
 
@@ -129,53 +133,25 @@ export function findColorMeetingRequirements(
129
133
  // Originally this was seed.oklch.l — although it's an assumption that works
130
134
  // only when we know for sure the direction of the search.
131
135
  // TODO: can we bring this back to seed.oklch.l ?
132
- let worseL = get( reference, [ OKLCH, 'l' ] );
133
- let worseContrast = 1;
134
- let replacedWorse = false;
135
- let betterL = mostContrastingL;
136
- let betterContrast = highestContrast;
137
- let replacedBetter = false;
138
-
139
- let bestColor: ColorTypes = mostContrastingColor;
140
- let bestContrast = highestContrast;
141
-
142
- for ( let i = 0; i < MAX_BISECTION_ITERATIONS; i++ ) {
143
- // Linear interpolation between worse and better L values, weighted by the contrast difference.
144
- const newL =
145
- ( worseL * ( betterContrast - target ) -
146
- betterL * ( worseContrast - target ) ) /
147
- ( betterContrast - worseContrast );
148
-
149
- bestColor = getColorForL( newL );
150
- bestContrast = getContrast( reference, bestColor );
151
-
152
- if ( Math.abs( bestContrast - target ) <= LIGHTNESS_EPSILON ) {
153
- break;
154
- }
155
-
156
- // Update one of the boundary L values, using the Illinois method.
157
- if ( bestContrast >= target ) {
158
- betterL = newL;
159
- betterContrast = bestContrast;
160
- if ( replacedBetter ) {
161
- worseContrast = ( worseContrast + target ) / 2;
162
- }
163
- replacedBetter = true;
164
- replacedWorse = false;
165
- } else {
166
- worseL = newL;
167
- worseContrast = bestContrast;
168
- if ( replacedWorse ) {
169
- betterContrast = ( betterContrast + target ) / 2;
170
- }
171
- replacedWorse = true;
172
- replacedBetter = false;
173
- }
174
- }
136
+ const lowerL = get( reference, [ OKLCH, 'l' ] );
137
+ const lowerContrast = cdiff( 1, target );
138
+ const upperL = mostContrastingL;
139
+ const upperContrast = cdiff( highestContrast, target );
140
+
141
+ const bestColor = solveWithBisect(
142
+ getColorForL,
143
+ ( c: ColorTypes ) => cdiff( getContrast( reference, c ), target ),
144
+ lowerL,
145
+ lowerContrast,
146
+ upperL,
147
+ upperContrast
148
+ );
175
149
 
176
150
  return {
177
151
  color: bestColor,
178
152
  reached: true,
179
- achieved: bestContrast,
153
+ achieved: target,
154
+ // Negative number that specifies how much room we have.
155
+ deficit: cdiff( target, highestContrast ),
180
156
  };
181
157
  }
@@ -22,6 +22,8 @@ import {
22
22
  sortByDependency,
23
23
  computeBetterFgColorDirection,
24
24
  adjustContrastTarget,
25
+ stepsForStep,
26
+ solveWithBisect,
25
27
  } from './utils';
26
28
 
27
29
  import type {
@@ -31,7 +33,7 @@ import type {
31
33
  RampConfig,
32
34
  RampResult,
33
35
  } from './types';
34
- import { LIGHTNESS_EPSILON, MAX_BISECTION_ITERATIONS } from './constants';
36
+ import { CONTRAST_EPSILON } from './constants';
35
37
 
36
38
  /**
37
39
  * Calculate a complete color ramp based on the provided configuration.
@@ -65,13 +67,11 @@ function calculateRamp( {
65
67
  value: number;
66
68
  };
67
69
  } ) {
68
- const rampResults = {} as Record<
69
- keyof Ramp,
70
- { color: string; warning: boolean }
71
- >;
72
- let SATISFIED_ALL_CONTRAST_REQUIREMENTS = true;
73
- let UNSATISFIED_DIRECTION: RampDirection = 'lighter';
74
- let MAX_WEIGHTED_DEFICIT = 0;
70
+ const rampResults = {} as Record< keyof Ramp, string >;
71
+ let warnings: string[] | undefined;
72
+ let maxDeficit = -Infinity;
73
+ let maxDeficitDirection: RampDirection = 'lighter';
74
+ let maxDeficitStep;
75
75
 
76
76
  // Keep track of the calculated colors, as they are going to be useful
77
77
  // when other colors reference them.
@@ -85,8 +85,8 @@ function calculateRamp( {
85
85
  taperChromaOptions,
86
86
  sameAsIfPossible,
87
87
  } = config[ stepName ];
88
- const referenceColor = calculatedColors.get( contrast.reference );
89
88
 
89
+ const referenceColor = calculatedColors.get( contrast.reference );
90
90
  if ( ! referenceColor ) {
91
91
  throw new Error(
92
92
  `Reference color for step ${ stepName } not found: ${ contrast.reference }`
@@ -96,23 +96,24 @@ function calculateRamp( {
96
96
  // Check if we can reuse color from the `sameAsIfPossible` config option
97
97
  if ( sameAsIfPossible ) {
98
98
  const candidateColor = calculatedColors.get( sameAsIfPossible );
99
- if ( candidateColor ) {
100
- const candidateContrast = getContrast(
101
- referenceColor,
102
- candidateColor
99
+ if ( ! candidateColor ) {
100
+ throw new Error(
101
+ `Same-as color for step ${ stepName } not found: ${ sameAsIfPossible }`
103
102
  );
104
- const adjustedTarget = adjustContrastTarget( contrast.target );
105
- // If the candidate meets the contrast requirement, use it
106
- if ( candidateContrast >= adjustedTarget ) {
107
- // Store the reused color
108
- calculatedColors.set( stepName, candidateColor );
109
- rampResults[ stepName ] = {
110
- color: getColorString( candidateColor ),
111
- warning: false,
112
- };
113
-
114
- continue; // Skip to next step
115
- }
103
+ }
104
+
105
+ const candidateContrast = getContrast(
106
+ referenceColor,
107
+ candidateColor
108
+ );
109
+ const adjustedTarget = adjustContrastTarget( contrast.target );
110
+ // If the candidate meets the contrast requirement, use it
111
+ if ( candidateContrast >= adjustedTarget ) {
112
+ // Store the reused color
113
+ calculatedColors.set( stepName, candidateColor );
114
+ rampResults[ stepName ] = getColorString( candidateColor );
115
+
116
+ continue; // Skip to next step
116
117
  }
117
118
  }
118
119
 
@@ -166,7 +167,6 @@ function calculateRamp( {
166
167
  adjustedTarget,
167
168
  computedDir,
168
169
  {
169
- strict: false,
170
170
  lightnessConstraint,
171
171
  taperChromaOptions,
172
172
  }
@@ -174,41 +174,34 @@ function calculateRamp( {
174
174
 
175
175
  // When the target contrast is not met, take note of it and use
176
176
  // that information to guide the ramp calculation bisection.
177
- if ( ! searchResults.reached && ! contrast.ignoreWhenAdjustingSeed ) {
178
- SATISFIED_ALL_CONTRAST_REQUIREMENTS = false;
179
-
180
- // Calculate constraint failure severity for seed optimization
181
- // Use the relative deficit size, weighted by how changing the seed would impact this constraint
182
- const deficitVsTarget = adjustedTarget - searchResults.achieved;
183
-
184
- // Weight the deficit by how much seed adjustment would help this constraint
185
- // If seed has low contrast vs reference, adjusting seed has high impact
186
- // If seed has high contrast vs reference, adjusting seed has low impact
187
- const impactWeight = 1 / getContrast( seed, referenceColor );
188
- const weightedDeficit = deficitVsTarget * impactWeight;
189
-
190
- // Track the most impactful failure for seed optimization
191
- if ( weightedDeficit > MAX_WEIGHTED_DEFICIT ) {
192
- MAX_WEIGHTED_DEFICIT = weightedDeficit;
193
- UNSATISFIED_DIRECTION = computedDir;
194
- }
177
+ if (
178
+ ! contrast.ignoreWhenAdjustingSeed &&
179
+ searchResults.deficit &&
180
+ searchResults.deficit > maxDeficit
181
+ ) {
182
+ maxDeficit = searchResults.deficit;
183
+ maxDeficitDirection = computedDir;
184
+ maxDeficitStep = stepName;
195
185
  }
196
186
 
197
187
  // Store calculated color for future dependencies
198
188
  calculatedColors.set( stepName, searchResults.color );
199
189
 
200
190
  // Add to results
201
- rampResults[ stepName ] = {
202
- color: getColorString( searchResults.color ),
203
- warning:
204
- ! contrast.ignoreWhenAdjustingSeed && ! searchResults.reached,
205
- };
191
+ rampResults[ stepName ] = getColorString( searchResults.color );
192
+
193
+ if ( ! searchResults.reached && ! contrast.ignoreWhenAdjustingSeed ) {
194
+ warnings ??= [];
195
+ warnings.push( stepName );
196
+ }
206
197
  }
207
198
 
208
199
  return {
209
200
  rampResults,
210
- SATISFIED_ALL_CONTRAST_REQUIREMENTS,
211
- UNSATISFIED_DIRECTION,
201
+ warnings,
202
+ maxDeficit,
203
+ maxDeficitDirection,
204
+ maxDeficitStep,
212
205
  };
213
206
  }
214
207
 
@@ -257,8 +250,10 @@ export function buildRamp(
257
250
  // Calculate the ramp with the initial seed.
258
251
  const {
259
252
  rampResults,
260
- SATISFIED_ALL_CONTRAST_REQUIREMENTS,
261
- UNSATISFIED_DIRECTION,
253
+ warnings,
254
+ maxDeficit,
255
+ maxDeficitDirection,
256
+ maxDeficitStep,
262
257
  } = calculateRamp( {
263
258
  seed,
264
259
  sortedSteps,
@@ -267,69 +262,74 @@ export function buildRamp(
267
262
  oppDir,
268
263
  pinLightness,
269
264
  } );
270
- const toReturn = {
271
- ramp: rampResults,
272
- direction: mainDir,
273
- } as RampResult;
274
265
 
275
- if (
276
- ! SATISFIED_ALL_CONTRAST_REQUIREMENTS &&
277
- rescaleToFitContrastTargets
278
- ) {
279
- let worseSeedL = get( seed, [ OKLCH, 'l' ] );
280
- // For a scale with the "lighter" direction, the contrast can be improved
281
- // by darkening the seed. For "darker" direction, by lightening the seed.
282
- let betterSeedL = UNSATISFIED_DIRECTION === 'lighter' ? 0 : 1;
283
-
284
- // Binary search: try a new seed and recompute the whole ramp
285
- // (TODO: try a smarter approach?)
286
- for (
287
- let i = 0;
288
- i < MAX_BISECTION_ITERATIONS &&
289
- Math.abs( betterSeedL - worseSeedL ) > LIGHTNESS_EPSILON;
290
- i++
291
- ) {
292
- const newSeed = clampToGamut(
293
- set(
294
- clone( seed ),
295
- [ OKLCH, 'l' ],
296
- ( worseSeedL + betterSeedL ) / 2
297
- )
298
- );
266
+ let bestRamp = rampResults;
299
267
 
268
+ if ( maxDeficit > CONTRAST_EPSILON && rescaleToFitContrastTargets ) {
269
+ const iterSteps = stepsForStep( maxDeficitStep!, config );
270
+
271
+ function getSeedForL( l: number ): ColorTypes {
272
+ return clampToGamut( set( clone( seed ), [ OKLCH, 'l' ], l ) );
273
+ }
274
+
275
+ function getDeficitForSeed( s: ColorTypes ): number {
300
276
  const iterationResults = calculateRamp( {
301
- seed: newSeed,
302
- sortedSteps,
277
+ seed: s,
278
+ sortedSteps: iterSteps,
303
279
  config,
304
280
  mainDir,
305
281
  oppDir,
306
282
  pinLightness,
307
283
  } );
308
284
 
309
- if ( iterationResults.SATISFIED_ALL_CONTRAST_REQUIREMENTS ) {
310
- betterSeedL = get( newSeed, [ OKLCH, 'l' ] );
311
- // Only update toReturn when the ramp satisfies all constraints.
312
- toReturn.ramp = iterationResults.rampResults;
313
- } else if ( UNSATISFIED_DIRECTION !== mainDir ) {
314
- // Failing constraint is in opposite direction to main ramp direction
315
- // We've moved too far in mainDir, constrain the search
316
- betterSeedL = get( newSeed, [ OKLCH, 'l' ] );
317
- } else {
318
- // Failing constraint is in same direction as main ramp direction
319
- // We haven't moved far enough in mainDir, continue searching
320
- worseSeedL = get( newSeed, [ OKLCH, 'l' ] );
321
- }
285
+ // If the constraints start failing in the opposite direction to the original
286
+ // iteration's direction, that means we've moved too far away from the target.
287
+ // Don't use the `maxDeficit` value because it's not related to our search,
288
+ // and might even be positive, which would confuse the bisection algorithm.
289
+ return iterationResults.maxDeficitDirection === maxDeficitDirection
290
+ ? iterationResults.maxDeficit
291
+ : -maxDeficit;
322
292
  }
293
+
294
+ // For a scale with the "lighter" direction, the contrast can be improved
295
+ // by darkening the seed. For "darker" direction, by lightening the seed.
296
+ const lowerSeedL = maxDeficitDirection === 'lighter' ? 0 : 1;
297
+ const lowerDeficit = -maxDeficit;
298
+ const upperSeedL = get( seed, [ OKLCH, 'l' ] );
299
+ const upperDeficit = maxDeficit;
300
+
301
+ const bestSeed = solveWithBisect(
302
+ getSeedForL,
303
+ getDeficitForSeed,
304
+ lowerSeedL,
305
+ lowerDeficit,
306
+ upperSeedL,
307
+ upperDeficit
308
+ );
309
+
310
+ // Calculate the final ramp with adjusted seed.
311
+ bestRamp = calculateRamp( {
312
+ seed: bestSeed,
313
+ sortedSteps,
314
+ config,
315
+ mainDir,
316
+ oppDir,
317
+ pinLightness,
318
+ } ).rampResults;
323
319
  }
324
320
 
325
321
  // Swap surface1 and surface3 for darker ramps to maintain visual elevation hierarchy.
326
322
  // This ensures surface1 appears "behind" surface2, and surface3 appears "in front",
327
323
  // regardless of the ramp's main direction.
328
324
  if ( mainDir === 'darker' ) {
329
- const tmpSurface1 = toReturn.ramp.surface1;
330
- toReturn.ramp.surface1 = toReturn.ramp.surface3;
331
- toReturn.ramp.surface3 = tmpSurface1;
325
+ const tmpSurface1 = bestRamp.surface1;
326
+ bestRamp.surface1 = bestRamp.surface3;
327
+ bestRamp.surface3 = tmpSurface1;
332
328
  }
333
329
 
334
- return toReturn;
330
+ return {
331
+ ramp: bestRamp,
332
+ warnings,
333
+ direction: mainDir,
334
+ };
335
335
  }
@@ -59,7 +59,7 @@ export const BG_RAMP_CONFIG: RampConfig = {
59
59
  contrast: {
60
60
  reference: 'surface2',
61
61
  followDirection: 'opposite',
62
- target: 1.02,
62
+ target: 1.06,
63
63
  ignoreWhenAdjustingSeed: true,
64
64
  },
65
65
  taperChromaOptions: BG_SURFACE_TAPER_CHROMA,
@@ -75,7 +75,7 @@ export const BG_RAMP_CONFIG: RampConfig = {
75
75
  contrast: {
76
76
  reference: 'surface2',
77
77
  followDirection: 'main',
78
- target: 1.02,
78
+ target: 1.06,
79
79
  },
80
80
  taperChromaOptions: BG_SURFACE_TAPER_CHROMA,
81
81
  },
@@ -83,7 +83,7 @@ export const BG_RAMP_CONFIG: RampConfig = {
83
83
  contrast: {
84
84
  reference: 'surface2',
85
85
  followDirection: 'main',
86
- target: 1.08,
86
+ target: 1.12,
87
87
  },
88
88
  taperChromaOptions: BG_SURFACE_TAPER_CHROMA,
89
89
  },
@@ -79,12 +79,7 @@ export type RampStepConfig = {
79
79
  export type RampConfig = Record< keyof Ramp, RampStepConfig >;
80
80
 
81
81
  export type RampResult = {
82
- ramp: Record<
83
- keyof Ramp,
84
- {
85
- color: string;
86
- warning: boolean;
87
- }
88
- >;
82
+ ramp: Record< keyof Ramp, string >;
83
+ warnings?: string[];
89
84
  direction: RampDirection;
90
85
  };
@@ -13,8 +13,10 @@ import {
13
13
  UNIVERSAL_CONTRAST_TOPUP,
14
14
  WHITE_TEXT_CONTRAST_MARGIN,
15
15
  ACCENT_SCALE_BASE_LIGHTNESS_THRESHOLDS,
16
+ MAX_BISECTION_ITERATIONS,
17
+ CONTRAST_EPSILON,
16
18
  } from './constants';
17
- import type { Ramp, RampStepConfig, RampDirection } from './types';
19
+ import type { Ramp, RampConfig, RampDirection } from './types';
18
20
  import { getContrast } from './color-utils';
19
21
 
20
22
  /**
@@ -28,7 +30,7 @@ export const clampToGamut = ( c: ColorTypes ) =>
28
30
  * Build a dependency graph from the steps configuration
29
31
  * @param config - The steps configuration object
30
32
  */
31
- function buildDependencyGraph( config: Record< keyof Ramp, RampStepConfig > ): {
33
+ function buildDependencyGraph( config: RampConfig ): {
32
34
  dependencies: Map< keyof Ramp, ( keyof Ramp | 'seed' )[] >;
33
35
  dependents: Map< keyof Ramp | 'seed', ( keyof Ramp )[] >;
34
36
  } {
@@ -66,9 +68,7 @@ function buildDependencyGraph( config: Record< keyof Ramp, RampStepConfig > ): {
66
68
  * Topologically sort steps based on their dependencies
67
69
  * @param config - The steps configuration object
68
70
  */
69
- export function sortByDependency(
70
- config: Record< keyof Ramp, RampStepConfig >
71
- ): ( keyof Ramp )[] {
71
+ export function sortByDependency( config: RampConfig ): ( keyof Ramp )[] {
72
72
  const { dependents } = buildDependencyGraph( config );
73
73
  const result: ( keyof Ramp )[] = [];
74
74
  const visited = new Set< keyof Ramp | 'seed' >();
@@ -108,6 +108,37 @@ export function sortByDependency(
108
108
 
109
109
  return result;
110
110
  }
111
+ /**
112
+ * Return minimal set of steps that are needed to calculate `stepName` from the seed.
113
+ * @param stepName Name of the step.
114
+ * @param config Configuration of the ramp.
115
+ * @return Array of steps that `stepName` depends on.
116
+ */
117
+ export function stepsForStep(
118
+ stepName: keyof Ramp,
119
+ config: RampConfig
120
+ ): ( keyof Ramp )[] {
121
+ const result = new Set< keyof Ramp >();
122
+ function visit( step: keyof Ramp | 'seed' ) {
123
+ if ( step === 'seed' || result.has( step ) ) {
124
+ return;
125
+ }
126
+
127
+ const stepConfig = config[ step ];
128
+ if ( ! stepConfig ) {
129
+ return;
130
+ }
131
+
132
+ visit( stepConfig.contrast.reference );
133
+ if ( stepConfig.sameAsIfPossible ) {
134
+ visit( stepConfig.sameAsIfPossible );
135
+ }
136
+
137
+ result.add( step );
138
+ }
139
+ visit( stepName );
140
+ return Array.from( result );
141
+ }
111
142
 
112
143
  /**
113
144
  * Finds out whether a lighter or a darker foreground color achieves a better
@@ -158,3 +189,76 @@ export function clampAccentScaleReferenceLightness(
158
189
  const thresholds = ACCENT_SCALE_BASE_LIGHTNESS_THRESHOLDS[ direction ];
159
190
  return Math.max( thresholds.min, Math.min( thresholds.max, rawLightness ) );
160
191
  }
192
+
193
+ /**
194
+ * Find the value of of `L` (luminance) that produces a `C` (color) that has a
195
+ * `value` (contrast delta) equal to zero.
196
+ * @param calculateC Calculate `C` from a given `L`.
197
+ * @param calculateValue Calculate value (delta) for a given `C`.
198
+ * @param initLowerL Initial lower value of `L`.
199
+ * @param initLowerValue Initial lower delta (negative).
200
+ * @param initUpperL Initial upper value of `L`.
201
+ * @param initUpperValue Initial upper delta (positive).
202
+ * @return Resulting value of type `C`.
203
+ */
204
+ export function solveWithBisect< C >(
205
+ calculateC: ( l: number ) => C,
206
+ calculateValue: ( t: C ) => number,
207
+ initLowerL: number,
208
+ initLowerValue: number,
209
+ initUpperL: number,
210
+ initUpperValue: number
211
+ ): C {
212
+ let lowerL = initLowerL;
213
+ let lowerValue = initLowerValue;
214
+ let lowerReplaced = false;
215
+
216
+ let upperL = initUpperL;
217
+ let upperValue = initUpperValue;
218
+ let upperReplaced = false;
219
+
220
+ let bestC: C;
221
+ let bestValue: number;
222
+ let iterations = 0;
223
+
224
+ while ( true ) {
225
+ iterations++;
226
+
227
+ // Linear interpolation: find the point where a line would cross the zero axis.
228
+ const newL =
229
+ ( lowerL * upperValue - upperL * lowerValue ) /
230
+ ( upperValue - lowerValue );
231
+
232
+ bestC = calculateC( newL );
233
+ bestValue = calculateValue( bestC );
234
+
235
+ if (
236
+ Math.abs( bestValue ) <= CONTRAST_EPSILON ||
237
+ iterations >= MAX_BISECTION_ITERATIONS
238
+ ) {
239
+ break;
240
+ }
241
+
242
+ // Update the lower/upper bracket values. When only one side is repeatedly updated,
243
+ // apply so-called "Illinois trick" for faster convergence: halve the opposite value.
244
+ if ( bestValue <= 0 ) {
245
+ lowerL = newL;
246
+ lowerValue = bestValue;
247
+ if ( lowerReplaced ) {
248
+ upperValue /= 2;
249
+ }
250
+ lowerReplaced = true;
251
+ upperReplaced = false;
252
+ } else {
253
+ upperL = newL;
254
+ upperValue = bestValue;
255
+ if ( upperReplaced ) {
256
+ lowerValue /= 2;
257
+ }
258
+ upperReplaced = true;
259
+ lowerReplaced = false;
260
+ }
261
+ }
262
+
263
+ return bestC;
264
+ }