From 70b277bade69e765f0b18003d62f2e794535398e Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Tue, 28 Nov 2023 10:25:14 +1000 Subject: [PATCH] add theme package --- .../builders/css-properties-from-theme.js | 24 ++++ packages/theme/color.js | 131 ++++++++++++++++++ packages/theme/index.js | 2 + packages/theme/package.json | 34 +++++ packages/theme/provider.js | 60 ++++++++ packages/theme/style.scss | 25 ++++ packages/theme/theme.js | 16 +++ packages/theme/utils.js | 20 +++ 8 files changed, 312 insertions(+) create mode 100644 packages/theme/builders/css-properties-from-theme.js create mode 100644 packages/theme/color.js create mode 100644 packages/theme/index.js create mode 100644 packages/theme/package.json create mode 100644 packages/theme/provider.js create mode 100644 packages/theme/style.scss create mode 100644 packages/theme/theme.js create mode 100644 packages/theme/utils.js diff --git a/packages/theme/builders/css-properties-from-theme.js b/packages/theme/builders/css-properties-from-theme.js new file mode 100644 index 00000000000000..da110ec82df4a9 --- /dev/null +++ b/packages/theme/builders/css-properties-from-theme.js @@ -0,0 +1,24 @@ +/** + * Internal dependencies + */ + +import { defaultTheme } from '../theme.js'; +import { themeToCss } from '../utils.js'; + +printStylesheet( defaultTheme ); + +function printStylesheet( theme ) { + const css = themeToCss( theme ); + + const contents = [ + `/* Generated by WordPress */`, + '\n\n', + ':root {', + '\n', + css, + '\n', + '}', + ]; + + return contents; +} diff --git a/packages/theme/color.js b/packages/theme/color.js new file mode 100644 index 00000000000000..987d7c93290eac --- /dev/null +++ b/packages/theme/color.js @@ -0,0 +1,131 @@ +/** + * External dependencies + */ +import { colord, extend } from 'colord'; +import a11yPlugin from 'colord/plugins/a11y'; +import namesPlugin from 'colord/plugins/names'; + +extend( [ namesPlugin, a11yPlugin ] ); + +const LIGHT_VALUES = [ 100, 98, 95, 92, 89, 87, 83, 73, 55, 48, 39, 13 ]; +const DARK_VALUES = [ 1, 11, 16, 19, 22, 18, 29, 38, 43, 73, 80, 93 ]; +export const PRIMARY_DEFAULT = '#3858e9'; + +// map showing which lightness in scale each use case should use +const COLOR_MAP = { + bg: { + default: 2, + hover: 3, + active: 4, + input: { + default: 0, + disabled: 0, + }, + muted: 1, + strong: { + default: 8, + hover: 9, + }, + }, + text: { + default: 10, + hover: 11, + strong: 11, + inverse: { + default: 1, + strong: 0, + }, + muted: 9, + }, + border: { + default: 5, + disabled: 4, + input: 6, + strong: { + default: 6, + hover: 7, + }, + muted: 4, + hover: 6, + }, +}; + +// generates a color palette based on a primary color +export const generateColors = ( { + color = PRIMARY_DEFAULT, + fun = 0, + isDark = false, +} ) => { + const neutral = generateNeutralColors( { color, fun, isDark } ); + const primary = generatePrimaryColors( { + color, + bg: neutral.bg.default, + isDark, + } ); + + return { + primary, + neutral, + }; +}; + +const generateNeutralColors = ( { + color = PRIMARY_DEFAULT, + fun = 0, + isDark = false, +} ) => { + const base = colord( color ).toHsl(); + const lightValues = isDark ? DARK_VALUES : LIGHT_VALUES; + const colors = lightValues.map( ( value ) => + colord( { ...base, s: fun, l: value } ).toHex() + ); + return mapColors( colors, COLOR_MAP ); +}; + +const generatePrimaryColors = ( { + color = PRIMARY_DEFAULT, + bg, + isDark = false, +} ) => { + const base = colord( color ).toHsl(); + const lightValues = isDark ? DARK_VALUES : LIGHT_VALUES; + + // if the color given has enough contrast agains the background, use that as the solid background colour and adjust the surrounding scale to proportionally move with it + const length = lightValues.length; + // Calculate the difference between the new value and the old value + const diff = base.l - lightValues[ 8 ]; + // Calculate the weight for adjusting values. Closer to base colour should adjust more. + const weight = ( index ) => 1 - Math.abs( 8 - index ) / ( length - 1 ); + // Adjust all values in the array based on their weight + let adjustedArray = [ ...lightValues ]; + if ( colord( bg ).isReadable( base ) ) { + adjustedArray = lightValues.map( ( value, index ) => { + const adjustment = diff * weight( index ); + return index === 8 ? base.l : value + adjustment; + } ); + } + + // convert colours to hex and set min and max lightness values + const colors = adjustedArray.map( ( value ) => + colord( { + ...base, + l: Math.min( Math.max( parseInt( value ), 0 ), 100 ), + } ).toHex() + ); + + return mapColors( colors, COLOR_MAP ); +}; + +// maps a color map to a color palette +const mapColors = ( mapFromArray, mapToObject ) => { + const map = {}; + Object.keys( mapToObject ).forEach( ( alias ) => { + const color = mapToObject[ alias ]; + if ( typeof color === 'object' ) { + map[ alias ] = mapColors( mapFromArray, color ); + } else { + map[ alias ] = mapFromArray[ parseInt( color ) ]; + } + } ); + return map; +}; diff --git a/packages/theme/index.js b/packages/theme/index.js new file mode 100644 index 00000000000000..e4de33d6135951 --- /dev/null +++ b/packages/theme/index.js @@ -0,0 +1,2 @@ +export { ThemeProvider } from './provider'; +export { defaultTheme } from './theme'; diff --git a/packages/theme/package.json b/packages/theme/package.json new file mode 100644 index 00000000000000..4164cb34d8a2d6 --- /dev/null +++ b/packages/theme/package.json @@ -0,0 +1,34 @@ +{ + "name": "@wordpress/theme", + "version": "1.0.0", + "description": "A collection of tokens that make up a WordPress theme.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "theme", + "variables", + "styles" + ], + "homepage": "https://github.com/WordPress/gutenberg", + "repository": { + "type": "git", + "url": "git+https://github.com/WordPress/gutenberg.git", + "directory": "packages/theme" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "files": [ + "index.js", + "style.scss" + ], + "main": "index.js", + "style": "style.scss", + "dependencies": { + "@wordpress/element": "file:../element", + "colord": "^2.7.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/theme/provider.js b/packages/theme/provider.js new file mode 100644 index 00000000000000..036991d8971ef4 --- /dev/null +++ b/packages/theme/provider.js @@ -0,0 +1,60 @@ +/** + * WordPress dependencies + */ +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { generateColors } from './color'; +import { themeToCss } from './utils'; + +// lightweight way to add styles to a class name +const toHash = ( str ) => { + let i = 0, + out = 11; + while ( i < str.length ) out = ( 101 * out + str.charCodeAt( i++ ) ) >>> 0; //eslint-disable-line no-bitwise + return 'wp-' + out; +}; + +const addStyle = ( target, className, cssText ) => { + const style = document.createElement( 'style' ); + style.id = className; + style.append( cssText ); + target.append( style ); +}; + +const merge = ( compiled, target ) => { + const name = toHash( compiled ); + if ( ! document.getElementById( name ) ) { + addStyle( target, name, `.${ name } { ${ compiled }}` ); + } + return name; +}; + +// theme provider component that generates a theme and adds appropriate tokens to the head +export const ThemeProvider = ( { + as = 'div', + color, + fun, + isDark, + ...props +} ) => { + const { className, children, ...rest } = props; + const styles = themeToCss( { + color: generateColors( { + color, + fun, + isDark, + } ), + } ); + const name = merge( styles, document.head ); + return createElement( + as, + { + className: [ name, className ].join( ' ' ), + ...rest, + }, + children + ); +}; diff --git a/packages/theme/style.scss b/packages/theme/style.scss new file mode 100644 index 00000000000000..4dde33074beac1 --- /dev/null +++ b/packages/theme/style.scss @@ -0,0 +1,25 @@ +:root { + --wp-theme-color-neutral-bg-surface: var(--wp-theme-color-neutral-1); + --wp-theme-color-neutral-bg-input: var(--wp-theme-color-neutral-1); + --wp-theme-color-neutral-text-inverse-strong: var(--wp-theme-color-neutral-1); + --wp-theme-color-neutral-bg-input-disabled: var(--wp-theme-color-neutral-1); + --wp-theme-color-neutral-bg-muted: var(--wp-theme-color-neutral-2); + --wp-theme-color-neutral-text-inverse: var(--wp-theme-color-neutral-2); + --wp-theme-color-neutral-bg: var(--wp-theme-color-neutral-3); + --wp-theme-color-neutral-bg-hover: var(--wp-theme-color-neutral-4); + --wp-theme-color-neutral-bg-active: var(--wp-theme-color-neutral-5); + --wp-theme-color-neutral-border: var(--wp-theme-color-neutral-6); + --wp-theme-color-neutral-border-disabled: var(--wp-theme-color-neutral-6); + --wp-theme-color-neutral-border-input: var(--wp-theme-color-neutral-7); + --wp-theme-color-neutral-border-strong: var(--wp-theme-color-neutral-7); + --wp-theme-color-neutral-border-hover: var(--wp-theme-color-neutral-7); + --wp-theme-color-neutral-border-strong-hover: var(--wp-theme-color-neutral-8); + --wp-theme-color-neutral-bg-strong: var(--wp-theme-color-neutral-9); + --wp-theme-color-neutral-bg-strong-hover: var(--wp-theme-color-neutral-10); + --wp-theme-color-neutral-text-muted: var(--wp-theme-color-neutral-10); + --wp-theme-color-neutral-text: var(--wp-theme-color-neutral-11); + --wp-theme-color-neutral-text-hover: var(--wp-theme-color-neutral-12); + --wp-theme-color-neutral-text-strong: var(--wp-theme-color-neutral-12); + --wp-theme-color-neutral-bg-inverse: var(--wp-theme-color-neutral-12); +} + diff --git a/packages/theme/theme.js b/packages/theme/theme.js new file mode 100644 index 00000000000000..0fd578185a1447 --- /dev/null +++ b/packages/theme/theme.js @@ -0,0 +1,16 @@ +/** + * Internal dependencies + */ +import { generateColors } from './color'; + +// // theme object +export const defaultTheme = { + // shadows: {...}, + // spacing: { ... }, + // borderRadius: { ... }, + // fonts: { ... }, + // fontSizes: { ... }, + // fontWeights: { ... }, + // lineHeights: { ... }, + colors: generateColors( {} ), +}; diff --git a/packages/theme/utils.js b/packages/theme/utils.js new file mode 100644 index 00000000000000..877037102b030d --- /dev/null +++ b/packages/theme/utils.js @@ -0,0 +1,20 @@ +// flattens the theme object to a single level +function flattenTheme( obj, parent, res = {} ) { + for ( const key in obj ) { + const propName = parent ? parent + '-' + key : key; + if ( typeof obj[ key ] === 'object' ) { + flattenTheme( obj[ key ], propName, res ); + } else { + res[ propName.replace( '-default', '' ) ] = obj[ key ]; + } + } + return res; +} + +// converts a theme object to a CSS string containing CSS variables +export const themeToCss = ( theme ) => { + const flattenedTheme = flattenTheme( theme ); + return Object.entries( flattenedTheme ) + .map( ( [ key, value ] ) => `--wp-theme-${ key }: ${ value };` ) + .join( '\n' ); +};