Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
b2a8306
Setup Storybook
ciampo Apr 9, 2025
892b45f
Add saxon prototype
ciampo Apr 9, 2025
654e2cb
Add storybook example
ciampo Apr 9, 2025
cef7df6
DRY color generation
ciampo Apr 10, 2025
d33f909
Add scheme to storybook controls
ciampo Apr 14, 2025
75e6c72
Move to a8c folder
ciampo Apr 14, 2025
c80f2dc
Copy radix color generation algo
ciampo Apr 14, 2025
3175b65
Fix ts errors
ciampo Apr 14, 2025
16448f0
Generate neutral and primary scales from Radix and output as css vars
ciampo Apr 14, 2025
95390a3
Fix ts errors in Storybook
ciampo Apr 14, 2025
7d8a224
Update Storybook to show the diff between a8c and radix colors
ciampo Apr 14, 2025
f67a441
Generate tokens from both a8c and radix color scales
ciampo Apr 14, 2025
2ec465e
Fix mapColors recursion
ciampo Apr 14, 2025
9a17dce
Update storybook CSS var prefixes
ciampo Apr 14, 2025
3f1242f
Remove unneeded color gen algos, tidy up
ciampo Apr 22, 2025
de5ea27
Improve Storybook
ciampo Apr 22, 2025
70a0c01
Reorganize color mapping code
ciampo Apr 23, 2025
663d79d
Move background assignment to algos file
ciampo Apr 23, 2025
ac1924c
Object destructure color gen arguments
ciampo Apr 23, 2025
282b579
Memoize computation of color tokens
ciampo Apr 23, 2025
c51385f
Fix some missing changes from previous background refactor
ciampo Apr 23, 2025
171e58e
Remove wide gamut and alpha -based colors
ciampo Apr 23, 2025
11753c0
Refactor how primary color's contrast text is computed
ciampo Apr 23, 2025
18159a5
Tweak Storybook
ciampo Apr 23, 2025
db3b25d
Add sample test to check text color contrast to Storybook
ciampo Apr 23, 2025
a5006d2
Typo
ciampo Apr 23, 2025
a24fdd3
Make local copy of preset scales, remove radix npm dependency
ciampo Apr 23, 2025
19e8901
Generate info / success / warning / error scales + tokens
ciampo Apr 23, 2025
55fa5ab
Shift color scale indexes
ciampo Apr 23, 2025
202e7e4
Use WP components to quickly demo theming UI
ciampo Apr 23, 2025
7658e01
Update yarn
ciampo May 2, 2025
f394dfb
testing color tokens, notes
ciampo May 2, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Generate info / success / warning / error scales + tokens
  • Loading branch information
ciampo committed Apr 23, 2025
commit 19e890193e6d50c9028236ac895eaa9a86b7fcb0
164 changes: 114 additions & 50 deletions packages/theme/src/color/algos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,6 @@ import Color from 'colorjs.io';
import * as PresetScales from './preset-scales';
import { ArrayOf12 } from './types';

// redo token maps
// add more colors (warning / info / success / error)
// add example UI
// go beyond color?

const arrayOf12 = [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 ] as const;

const grayScaleNames = [ 'gray', 'mauve', 'slate', 'sage', 'olive', 'sand' ] as const;
Expand Down Expand Up @@ -92,74 +87,143 @@ function getReferenceBackgroundColor( appearance: 'light' | 'dark' ) {
export const generateColorScales = ( {
appearance,
accent,
info,
success,
warning,
error,
gray,
}: {
appearance: 'light' | 'dark';
accent: string;
info: string;
success: string;
warning: string;
error: string;
gray: string;
} ) => {
const allScales = appearance === 'light' ? lightColors : darkColors;
const grayScales = appearance === 'light' ? lightGrayColors : darkGrayColors;
const backgroundColor = getReferenceBackgroundColor( appearance );

const grayBaseColor = new Color( gray ).to( 'oklch' );
const grayScaleColors = getScaleFromColor( grayBaseColor, grayScales, backgroundColor );
const computedGrayScale = computeGrayScale( gray, appearance, backgroundColor );
const computedAccentScale = computeColoredScale(
accent,
appearance,
backgroundColor,
computedGrayScale.grayScale
);
const computedInfoScale = computeColoredScale(
info,
appearance,
backgroundColor,
computedGrayScale.grayScale
);
const computedSuccessScale = computeColoredScale(
success,
appearance,
backgroundColor,
computedGrayScale.grayScale
);
const computedWarningScale = computeColoredScale(
warning,
appearance,
backgroundColor,
computedGrayScale.grayScale
);
const computedErrorScale = computeColoredScale(
error,
appearance,
backgroundColor,
computedGrayScale.grayScale
);

return {
// Accent
accentScale: computedAccentScale.generatedScaleHex,
accentContrastSmallText: computedAccentScale.contrastColorSmallTextHex,
accentContrastLargeText: computedAccentScale.contrastColorLargeTextHex,
// Info
infoScale: computedInfoScale.generatedScaleHex,
infoContrastSmallText: computedInfoScale.contrastColorSmallTextHex,
infoContrastLargeText: computedInfoScale.contrastColorLargeTextHex,
// Success
successScale: computedSuccessScale.generatedScaleHex,
successContrastSmallText: computedSuccessScale.contrastColorSmallTextHex,
successContrastLargeText: computedSuccessScale.contrastColorLargeTextHex,
// Warning
warningScale: computedWarningScale.generatedScaleHex,
warningContrastSmallText: computedWarningScale.contrastColorSmallTextHex,
warningContrastLargeText: computedWarningScale.contrastColorLargeTextHex,
// Error
errorScale: computedErrorScale.generatedScaleHex,
errorContrastSmallText: computedErrorScale.contrastColorSmallTextHex,
errorContrastLargeText: computedErrorScale.contrastColorLargeTextHex,
// Gray
grayScale: computedGrayScale.grayScaleHex,
// Background (Enforce srgb for the background color)
background: backgroundColor.to( 'srgb' ).toString( { format: 'hex' } ),
};
};

function computeGrayScale(
graySeedColor: string,
appearance: 'light' | 'dark',
backgroundColor: Color
) {
const grayScales = appearance === 'light' ? lightGrayColors : darkGrayColors;

const grayBaseColor = new Color( graySeedColor ).to( 'oklch' );
const generatedGrayScale = getScaleFromColor( grayBaseColor, grayScales, backgroundColor );

const accentBaseColor = new Color( accent ).to( 'oklch' );
return {
grayScale: generatedGrayScale,
grayScaleHex: generatedGrayScale.map( ( color ) =>
color.to( 'srgb' ).toString( { format: 'hex' } )
) as ArrayOf12< string >,
};
}

let accentScaleColors = getScaleFromColor( accentBaseColor, allScales, backgroundColor );
function computeColoredScale(
seedColor: string,
appearance: 'light' | 'dark',
backgroundColor: Color,
grayScaleColors: ArrayOf12< Color >
) {
const allScales = appearance === 'light' ? lightColors : darkColors;
const baseColor = new Color( seedColor ).to( 'oklch' );
const baseColorHex = baseColor.to( 'srgb' ).toString( { format: 'hex' } );

// Enforce srgb for the background color
const backgroundHex = backgroundColor.to( 'srgb' ).toString( { format: 'hex' } );
let generatedScale = getScaleFromColor( baseColor, allScales, backgroundColor );

// Make sure we use the tint from the gray scale for when base is pure white or black
const accentBaseHex = accentBaseColor.to( 'srgb' ).toString( { format: 'hex' } );
if ( accentBaseHex === '#000' || accentBaseHex === '#fff' ) {
accentScaleColors = grayScaleColors.map( ( color ) => color.clone() ) as ArrayOf12< Color >;
if ( baseColorHex === '#000' || baseColorHex === '#fff' ) {
generatedScale = grayScaleColors.map( ( color ) => color.clone() ) as ArrayOf12< Color >;
}

const [ accent9Color, accentContrastColorSmallText, accentContrastColorLargeText ] =
getStep9Colors( accentScaleColors, accentBaseColor );
const [ step9Color, contrastColorSmallText, contrastColorLargeText ] = getStep9Colors(
generatedScale,
baseColor
);

accentScaleColors[ 8 ] = accent9Color;
accentScaleColors[ 9 ] = getButtonHoverColor( accent9Color, [ accentScaleColors ] );
generatedScale[ 8 ] = step9Color;
generatedScale[ 9 ] = getButtonHoverColor( step9Color, [ generatedScale ] );

// Limit saturation of the text colors
accentScaleColors[ 10 ].coords[ 1 ] = Math.min(
Math.max( accentScaleColors[ 8 ].coords[ 1 ], accentScaleColors[ 7 ].coords[ 1 ] ),
accentScaleColors[ 10 ].coords[ 1 ]
generatedScale[ 10 ].coords[ 1 ] = Math.min(
Math.max( generatedScale[ 8 ].coords[ 1 ], generatedScale[ 7 ].coords[ 1 ] ),
generatedScale[ 10 ].coords[ 1 ]
);
accentScaleColors[ 11 ].coords[ 1 ] = Math.min(
Math.max( accentScaleColors[ 8 ].coords[ 1 ], accentScaleColors[ 7 ].coords[ 1 ] ),
accentScaleColors[ 11 ].coords[ 1 ]
generatedScale[ 11 ].coords[ 1 ] = Math.min(
Math.max( generatedScale[ 8 ].coords[ 1 ], generatedScale[ 7 ].coords[ 1 ] ),
generatedScale[ 11 ].coords[ 1 ]
);

const accentScaleHex = accentScaleColors.map( ( color ) =>
color.to( 'srgb' ).toString( { format: 'hex' } )
) as ArrayOf12< string >;

const accentContrastColorSmallTextHex = accentContrastColorSmallText
.to( 'srgb' )
.toString( { format: 'hex' } );
const accentContrastColorLargeTextHex = accentContrastColorLargeText
.to( 'srgb' )
.toString( { format: 'hex' } );

const grayScaleHex = grayScaleColors.map( ( color ) =>
color.to( 'srgb' ).toString( { format: 'hex' } )
) as ArrayOf12< string >;

return {
// Accent
accentScale: accentScaleHex,
accentContrastSmallText: accentContrastColorSmallTextHex,
accentContrastLargeText: accentContrastColorLargeTextHex,
// Gray
grayScale: grayScaleHex,
// Background
background: backgroundHex,
generatedScaleHex: generatedScale.map( ( color ) =>
color.to( 'srgb' ).toString( { format: 'hex' } )
) as ArrayOf12< string >,
contrastColorSmallTextHex: contrastColorSmallText.to( 'srgb' ).toString( { format: 'hex' } ),
contrastColorLargeTextHex: contrastColorLargeText.to( 'srgb' ).toString( { format: 'hex' } ),
};
};
}

function getStep9Colors(
scale: ArrayOf12< Color >,
Expand Down
29 changes: 28 additions & 1 deletion packages/theme/src/color/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Color from 'colorjs.io';
import { ThemeProps, TokensObject } from '../types';
import { generateColorScales } from './algos';
import { COLOR_MAP } from './map';
import { amber, green, red, blue } from './preset-scales';
import { mapColorsToScale } from './utils';

export function generateColors( color: ThemeProps[ 'color' ] ) {
Expand All @@ -11,17 +12,43 @@ export function generateColors( color: ThemeProps[ 'color' ] ) {

const colorScales = generateColorScales( {
accent: color.primary,
info: color.info ?? blue.blue9,
success: color.success ?? green.green9,
warning: color.warning ?? amber.amber9,
error: color.error ?? red.red9,
appearance: color.scheme ?? 'light',
gray: `hsl(${ primaryHue }deg ${ color.fun }% 50%)`,
} );

return {
background: colorScales.background,
// TODO: need contrast text for neutral?
[ 'neutral-scale' ]: colorScales.grayScale,
neutral: mapColorsToScale( colorScales.grayScale, COLOR_MAP ),
// Primary
[ 'primary-scale' ]: colorScales.accentScale,
[ 'primary-contrast-small' ]: colorScales.accentContrastSmallText,
[ 'primary-contrast-large' ]: colorScales.accentContrastLargeText,
neutral: mapColorsToScale( colorScales.grayScale, COLOR_MAP ),
primary: mapColorsToScale( colorScales.accentScale, COLOR_MAP ),
// Info
[ 'info-scale' ]: colorScales.infoScale,
[ 'info-contrast-small' ]: colorScales.infoContrastSmallText,
[ 'info-contrast-large' ]: colorScales.infoContrastLargeText,
info: mapColorsToScale( colorScales.infoScale, COLOR_MAP ),
// Success
[ 'success-scale' ]: colorScales.successScale,
[ 'success-contrast-small' ]: colorScales.successContrastSmallText,
[ 'success-contrast-large' ]: colorScales.successContrastLargeText,
success: mapColorsToScale( colorScales.successScale, COLOR_MAP ),
// Warning
[ 'warning-scale' ]: colorScales.warningScale,
[ 'warning-contrast-small' ]: colorScales.warningContrastSmallText,
[ 'warning-contrast-large' ]: colorScales.warningContrastLargeText,
warning: mapColorsToScale( colorScales.warningScale, COLOR_MAP ),
// Error
[ 'error-scale' ]: colorScales.errorScale,
[ 'error-contrast-small' ]: colorScales.errorContrastSmallText,
[ 'error-contrast-large' ]: colorScales.errorContrastLargeText,
error: mapColorsToScale( colorScales.errorScale, COLOR_MAP ),
} as TokensObject;
}
18 changes: 18 additions & 0 deletions packages/theme/src/color/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,21 @@ export type ColorScaleIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11;
export type ColorMap = {
[ key: string ]: ColorScaleIndex | ColorMap;
};

// TODO: define token map (pressed, inverse?)
// type Element = 'text' | 'stroke' | 'icon' | 'background';
// // type Tone = 'neutral' | 'error' | 'warning' | 'success' | 'info';
// type Emphasis = 'strong' | 'neutral' | 'weak';
// type State = 'hover' | 'active' | 'focus' | 'disabled' | 'selected';

// export type ColorMapSpec = {
// [ key in Element ]: {
// // [ key in Tone ]: {
// [ key in Emphasis ]:
// | ColorScaleIndex
// | {
// [ key in State ]: ColorScaleIndex;
// };
// // };
// };
// };
51 changes: 42 additions & 9 deletions packages/theme/src/index.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,26 @@ const meta: Meta< typeof Theme > = {
type: 'color',
},
},
info: {
control: {
type: 'color',
},
},
success: {
control: {
type: 'color',
},
},
warning: {
control: {
type: 'color',
},
},
error: {
control: {
type: 'color',
},
},
fun: {
control: { type: 'range', min: 0, max: 100, step: 1 },
},
Expand All @@ -37,23 +57,29 @@ const meta: Meta< typeof Theme > = {

export default meta;

type ExtraStorybookControls = {
primary: string;
fun: number;
scheme: 'light' | 'dark';
};
type ExtraStorybookControls = React.ComponentProps< typeof Theme >[ 'color' ];

type Story = StoryObj< React.ComponentProps< typeof Theme > & ExtraStorybookControls >;

export const Default: Story = {
render: ( args ) => (
<Theme color={ { primary: args.primary, fun: args.fun, scheme: args.scheme } }>
<Theme
color={ {
primary: args.primary,
info: args.info,
success: args.success,
warning: args.warning,
error: args.error,
fun: args.fun,
scheme: args.scheme,
} }
>
{ args.children }
</Theme>
),
args: {
primary: '#f00',
fun: 0,
primary: '#F76B15',
fun: 10,
scheme: 'light',
children: (
<>
Expand Down Expand Up @@ -103,7 +129,14 @@ export const Default: Story = {
</div>
) ) }
{ /* Scales */ }
{ [ 'neutral-scale', 'primary-scale' ].map( ( scaleName, i ) => [
{ [
'primary-scale',
'neutral-scale',
'info-scale',
'success-scale',
'warning-scale',
'error-scale',
].map( ( scaleName, i ) => [
<div
key={ `${ scaleName }-label-${ i }` }
style={ {
Expand Down
6 changes: 3 additions & 3 deletions packages/theme/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ declare module 'react' {
}

const Theme = forwardRef< HTMLDivElement, ThemeProps >( function Theme( { color, children }, ref ) {
const { primary, fun, scheme } = color;
const { primary, info, success, warning, error, fun, scheme } = color;
const colorTokens = useMemo(
// Recalculate the color tokens only when necessary.
() => generateColors( { primary, scheme, fun } ),
[ primary, scheme, fun ]
() => generateColors( { primary, info, success, warning, error, scheme, fun } ),
[ primary, info, success, warning, error, scheme, fun ]
);

const themeCss = themeToCss( { color: colorTokens } );
Expand Down
4 changes: 4 additions & 0 deletions packages/theme/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
export type ThemeProps = {
color: {
primary: string;
info?: string;
success?: string;
warning?: string;
error?: string;
fun?: number;
scheme?: 'dark' | 'light';
};
Expand Down