@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.
- package/LICENSE.md +788 -0
- package/README.md +67 -0
- package/bin/build-tokens.js +83 -0
- package/bin/generate-primitive-tokens/index.ts +115 -0
- package/bin/terrazzo-plugin-ds-tokens-docs/index.ts +103 -0
- package/bin/terrazzo-plugin-figma-ds-token-manager/index.ts +210 -0
- package/bin/terrazzo-plugin-figma-ds-token-manager/lib.ts +1 -0
- package/bin/terrazzo-plugin-known-wpds-css-variables/index.ts +72 -0
- package/build/color-ramps/index.js +132 -0
- package/build/color-ramps/index.js.map +7 -0
- package/build/color-ramps/lib/cache-utils.js +57 -0
- package/build/color-ramps/lib/cache-utils.js.map +7 -0
- package/build/color-ramps/lib/constants.js +105 -0
- package/build/color-ramps/lib/constants.js.map +7 -0
- package/build/color-ramps/lib/find-color-with-constraints.js +141 -0
- package/build/color-ramps/lib/find-color-with-constraints.js.map +7 -0
- package/build/color-ramps/lib/index.js +264 -0
- package/build/color-ramps/lib/index.js.map +7 -0
- package/build/color-ramps/lib/ramp-configs.js +315 -0
- package/build/color-ramps/lib/ramp-configs.js.map +7 -0
- package/build/color-ramps/lib/taper-chroma.js +159 -0
- package/build/color-ramps/lib/taper-chroma.js.map +7 -0
- package/build/color-ramps/lib/types.js +17 -0
- package/build/color-ramps/lib/types.js.map +7 -0
- package/build/color-ramps/lib/utils.js +106 -0
- package/build/color-ramps/lib/utils.js.map +7 -0
- package/build/context.js +34 -0
- package/build/context.js.map +7 -0
- package/build/index.js +29 -0
- package/build/index.js.map +7 -0
- package/build/lock-unlock.js +35 -0
- package/build/lock-unlock.js.map +7 -0
- package/build/prebuilt/js/design-tokens.js +135 -0
- package/build/prebuilt/js/design-tokens.js.map +7 -0
- package/build/prebuilt/json/figma.json +1317 -0
- package/build/prebuilt/ts/design-tokens.js +354 -0
- package/build/prebuilt/ts/design-tokens.js.map +7 -0
- package/build/private-apis.js +36 -0
- package/build/private-apis.js.map +7 -0
- package/build/style.module.css.js +2 -0
- package/build/theme-provider.js +92 -0
- package/build/theme-provider.js.map +7 -0
- package/build/types/css-modules.d.js +2 -0
- package/build/types/css-modules.d.js.map +7 -0
- package/build/types.js +17 -0
- package/build/types.js.map +7 -0
- package/build/use-theme-provider-styles.js +230 -0
- package/build/use-theme-provider-styles.js.map +7 -0
- package/build-module/color-ramps/index.js +95 -0
- package/build-module/color-ramps/index.js.map +7 -0
- package/build-module/color-ramps/lib/cache-utils.js +31 -0
- package/build-module/color-ramps/lib/cache-utils.js.map +7 -0
- package/build-module/color-ramps/lib/constants.js +63 -0
- package/build-module/color-ramps/lib/constants.js.map +7 -0
- package/build-module/color-ramps/lib/find-color-with-constraints.js +112 -0
- package/build-module/color-ramps/lib/find-color-with-constraints.js.map +7 -0
- package/build-module/color-ramps/lib/index.js +235 -0
- package/build-module/color-ramps/lib/index.js.map +7 -0
- package/build-module/color-ramps/lib/ramp-configs.js +290 -0
- package/build-module/color-ramps/lib/ramp-configs.js.map +7 -0
- package/build-module/color-ramps/lib/taper-chroma.js +125 -0
- package/build-module/color-ramps/lib/taper-chroma.js.map +7 -0
- package/build-module/color-ramps/lib/types.js +1 -0
- package/build-module/color-ramps/lib/types.js.map +7 -0
- package/build-module/color-ramps/lib/utils.js +84 -0
- package/build-module/color-ramps/lib/utils.js.map +7 -0
- package/build-module/context.js +10 -0
- package/build-module/context.js.map +7 -0
- package/build-module/index.js +5 -0
- package/build-module/index.js.map +7 -0
- package/build-module/lock-unlock.js +10 -0
- package/build-module/lock-unlock.js.map +7 -0
- package/build-module/prebuilt/js/design-tokens.js +115 -0
- package/build-module/prebuilt/js/design-tokens.js.map +7 -0
- package/build-module/prebuilt/json/figma.json +1317 -0
- package/build-module/prebuilt/ts/design-tokens.js +334 -0
- package/build-module/prebuilt/ts/design-tokens.js.map +7 -0
- package/build-module/private-apis.js +12 -0
- package/build-module/private-apis.js.map +7 -0
- package/build-module/style.module.css.js +1 -0
- package/build-module/theme-provider.js +58 -0
- package/build-module/theme-provider.js.map +7 -0
- package/build-module/types/css-modules.d.js +1 -0
- package/build-module/types/css-modules.d.js.map +7 -0
- package/build-module/types.js +1 -0
- package/build-module/types.js.map +7 -0
- package/build-module/use-theme-provider-styles.js +200 -0
- package/build-module/use-theme-provider-styles.js.map +7 -0
- package/build-style/style.css +3 -0
- package/build-types/color-ramps/index.d.ts +44 -0
- package/build-types/color-ramps/index.d.ts.map +1 -0
- package/build-types/color-ramps/lib/cache-utils.d.ts +22 -0
- package/build-types/color-ramps/lib/cache-utils.d.ts.map +1 -0
- package/build-types/color-ramps/lib/constants.d.ts +38 -0
- package/build-types/color-ramps/lib/constants.d.ts.map +1 -0
- package/build-types/color-ramps/lib/find-color-with-constraints.d.ts +37 -0
- package/build-types/color-ramps/lib/find-color-with-constraints.d.ts.map +1 -0
- package/build-types/color-ramps/lib/index.d.ts +11 -0
- package/build-types/color-ramps/lib/index.d.ts.map +1 -0
- package/build-types/color-ramps/lib/ramp-configs.d.ts +7 -0
- package/build-types/color-ramps/lib/ramp-configs.d.ts.map +1 -0
- package/build-types/color-ramps/lib/taper-chroma.d.ts +32 -0
- package/build-types/color-ramps/lib/taper-chroma.d.ts.map +1 -0
- package/build-types/color-ramps/lib/types.d.ts +78 -0
- package/build-types/color-ramps/lib/types.d.ts.map +1 -0
- package/build-types/color-ramps/lib/utils.d.ts +38 -0
- package/build-types/color-ramps/lib/utils.d.ts.map +1 -0
- package/build-types/color-ramps/stories/index.story.d.ts +14 -0
- package/build-types/color-ramps/stories/index.story.d.ts.map +1 -0
- package/build-types/color-ramps/stories/ramp-table.d.ts +19 -0
- package/build-types/color-ramps/stories/ramp-table.d.ts.map +1 -0
- package/build-types/context.d.ts +10 -0
- package/build-types/context.d.ts.map +1 -0
- package/build-types/index.d.ts +2 -0
- package/build-types/index.d.ts.map +1 -0
- package/build-types/lock-unlock.d.ts +2 -0
- package/build-types/lock-unlock.d.ts.map +1 -0
- package/build-types/prebuilt/js/design-tokens.d.ts +3 -0
- package/build-types/prebuilt/js/design-tokens.d.ts.map +1 -0
- package/build-types/prebuilt/ts/design-tokens.d.ts +7 -0
- package/build-types/prebuilt/ts/design-tokens.d.ts.map +1 -0
- package/build-types/private-apis.d.ts +2 -0
- package/build-types/private-apis.d.ts.map +1 -0
- package/build-types/stories/index.story.d.ts +15 -0
- package/build-types/stories/index.story.d.ts.map +1 -0
- package/build-types/theme-provider.d.ts +3 -0
- package/build-types/theme-provider.d.ts.map +1 -0
- package/build-types/types.d.ts +42 -0
- package/build-types/types.d.ts.map +1 -0
- package/build-types/use-theme-provider-styles.d.ts +17 -0
- package/build-types/use-theme-provider-styles.d.ts.map +1 -0
- package/docs/ds-tokens.md +283 -0
- package/package.json +58 -0
- package/src/color-ramps/index.ts +155 -0
- package/src/color-ramps/lib/cache-utils.ts +56 -0
- package/src/color-ramps/lib/constants.ts +85 -0
- package/src/color-ramps/lib/find-color-with-constraints.ts +190 -0
- package/src/color-ramps/lib/index.ts +369 -0
- package/src/color-ramps/lib/ramp-configs.ts +309 -0
- package/src/color-ramps/lib/taper-chroma.ts +226 -0
- package/src/color-ramps/lib/types.ts +90 -0
- package/src/color-ramps/lib/utils.ts +161 -0
- package/src/color-ramps/stories/index.story.tsx +264 -0
- package/src/color-ramps/stories/ramp-table.tsx +212 -0
- package/src/color-ramps/test/__snapshots__/index.test.ts.snap +1280 -0
- package/src/color-ramps/test/index.test.ts +94 -0
- package/src/context.ts +19 -0
- package/src/index.ts +2 -0
- package/src/lock-unlock.ts +10 -0
- package/src/prebuilt/css/design-tokens.css +401 -0
- package/src/prebuilt/js/design-tokens.js +116 -0
- package/src/prebuilt/json/figma.json +1317 -0
- package/src/prebuilt/ts/design-tokens.ts +335 -0
- package/src/private-apis.ts +12 -0
- package/src/stories/index.story.tsx +426 -0
- package/src/style.module.css +3 -0
- package/src/theme-provider.tsx +87 -0
- package/src/types/css-modules.d.ts +4 -0
- package/src/types.ts +44 -0
- package/src/use-theme-provider-styles.ts +247 -0
- package/terrazzo.config.ts +102 -0
- package/tokens/border.json +34 -0
- package/tokens/color.json +877 -0
- package/tokens/elevation.json +201 -0
- package/tokens/spacing.json +45 -0
- package/tokens/typography.json +93 -0
- package/tsconfig.json +9 -0
- 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
|
+
}
|