From 8f96a5669d872e8b42df9119dd6fd78fe4715d52 Mon Sep 17 00:00:00 2001 From: Thibaud Colas Date: Sat, 9 Jul 2022 06:58:26 +0100 Subject: [PATCH] Switch Tailwind color theme to use customisable CSS variables Co-authored-by: Scott Cranfill --- client/src/tokens/colorVariables.js | 90 +++++++++++++++++ client/src/tokens/colorVariables.test.js | 121 +++++++++++++++++++++++ client/src/tokens/colors.js | 72 +++++++++++--- client/tailwind.config.js | 27 +++-- 4 files changed, 291 insertions(+), 19 deletions(-) create mode 100644 client/src/tokens/colorVariables.js create mode 100644 client/src/tokens/colorVariables.test.js diff --git a/client/src/tokens/colorVariables.js b/client/src/tokens/colorVariables.js new file mode 100644 index 0000000000..c4bdf601ed --- /dev/null +++ b/client/src/tokens/colorVariables.js @@ -0,0 +1,90 @@ +/** + * Generate a CSS calc() expression so one HSL component is derived from another. + * For example, with a reference color of `hsl(66 50% 25%)`, and a derived color of hsl(66 65% 20%), + * - The hue components are identical, so the derived hue should just refer to the reference hue. + * - Saturation for the derived color is higher, so should be `calc(var(--ref-saturation) + 15%)` + * - Lightness for the derived color is lower, so should be `calc(var(--ref-lightness) - 5%)`. + */ +const calcHSLDifference = (refVariable, refValue, value, unit = '') => { + const ref = Number(refValue); + const val = Number(value); + + // If the value is equal to the reference, there is nothing to calc. + if (ref === val) { + return `var(${refVariable})`; + } + + // Either add or remove the difference based on whether it’s positive or negative. + const diff = ref - val; + const operation = `${diff > 0 ? '-' : '+'} ${Math.abs(diff)}${unit}`; + + return `calc(var(${refVariable}) ${operation})`; +}; + +/** + * Generate customisable CSS variables for a color palette, with override-able HSL components. + * + * For each shade of a color, we want to generate four variables: + * - One for each HSL component of the color (Hue, Saturation, Lightness). + * - A valid HSL color value combining the three components. + * + * A shade’s HSL components need to be derived from the reference color’s HSL components, + * so site implementers can change all shades of a color at once by setting the HSL components of the "reference" color. + * + * For example, for a "light red" color derived from "red", this will create: + * --red-light-hue: var(--red-hue); + * --red-light-saturation: var(--red-saturation + 15%); + * --red-light-lightness: calc(var(--red-lightness) - 5%); + * --red-light: hsl(var(--red-light-hue) var(--red-light-saturation) var(--red-light-lightness)); + * + * For the red reference color defined as `hsl(66 50% 25%)`, this will create: + * --red-hue: 66; + * --red-saturation: 50%; + * --red-lightness: 25%; + * --red: hsl(var(--red-hue) var(--red-saturation) var(--red-lightness)); + * + */ +const generateColorVariables = (colors) => { + /* eslint-disable no-param-reassign, id-length */ + const colorVariables = Object.values(colors).reduce((root, hues) => { + // Use the DEFAULT hue as a reference to derive others from, or the darkest if there is no defined default. + const darkestHue = Object.keys(hues).sort((a, b) => b - a)[0]; + const reference = hues.DEFAULT || hues[darkestHue]; + const [refH, refS, refL] = reference.hsl.match(/\d+/g); + const refVar = reference.cssVariable; + + // Generate color variables for all individual color shades, based on the reference. + Object.values(hues).forEach((shade) => { + // CSS variables will we generate. + const vars = { + hsl: shade.cssVariable, + h: `${shade.cssVariable}-hue`, + s: `${shade.cssVariable}-saturation`, + l: `${shade.cssVariable}-lightness`, + }; + const [h, s, l] = shade.hsl.match(/\d+/g); + const isReferenceShade = reference.hex === shade.hex; + + if (isReferenceShade) { + // If this is the reference shade, we use its HSL values as-is. + root[vars.h] = h; + root[vars.s] = `${s}%`; + root[vars.l] = `${l}%`; + } else { + // If this is a derived shade, we will derive its HSL values from the reference. + root[vars.h] = calcHSLDifference(`${refVar}-hue`, refH, h); + root[vars.s] = calcHSLDifference(`${refVar}-saturation`, refS, s, '%'); + root[vars.l] = calcHSLDifference(`${refVar}-lightness`, refL, l, '%'); + } + + root[vars.hsl] = `hsl(var(${vars.h}) var(${vars.s}) var(${vars.l}))`; + }); + + return root; + }, {}); + return colorVariables; +}; + +module.exports = { + generateColorVariables, +}; diff --git a/client/src/tokens/colorVariables.test.js b/client/src/tokens/colorVariables.test.js new file mode 100644 index 0000000000..80ecf29dbf --- /dev/null +++ b/client/src/tokens/colorVariables.test.js @@ -0,0 +1,121 @@ +const colors = require('./colors'); +const { generateColorVariables } = require('./colorVariables'); + +describe('generateColorVariables', () => { + it('generates all variables', () => { + const colorVariables = generateColorVariables(colors); + const generatedVariables = Object.keys(colorVariables); + Object.values(colors).forEach((hues) => { + Object.values(hues).forEach((shade) => { + expect(generatedVariables).toContain(shade.cssVariable); + }); + }); + }); + + /** + * If this test breaks, it means we’ve either changed our color palette, or changed how we make each of the colors customisable. + * If the change is intentional, we will then need to update our `custom_user_interface_colours` documentation. + * - Open Storybook’s color customisation story in a browser + * - Use your browser’s DevTools to copy the relevant story markup to our Markdown documentation. + * - Leave the copied content exactly as-is when pasting, to avoid any Markdown formatting issues. + */ + it('is stable (update custom_user_interface_colours documentation when this changes)', () => { + const colorVariables = generateColorVariables(colors); + expect(colorVariables).toMatchInlineSnapshot(` + Object { + "--w-color-black": "hsl(var(--w-color-black-hue) var(--w-color-black-saturation) var(--w-color-black-lightness))", + "--w-color-black-hue": "0", + "--w-color-black-lightness": "0%", + "--w-color-black-saturation": "0%", + "--w-color-critical-100": "hsl(var(--w-color-critical-100-hue) var(--w-color-critical-100-saturation) var(--w-color-critical-100-lightness))", + "--w-color-critical-100-hue": "calc(var(--w-color-critical-200-hue) + 355)", + "--w-color-critical-100-lightness": "calc(var(--w-color-critical-200-lightness) + 13%)", + "--w-color-critical-100-saturation": "calc(var(--w-color-critical-200-saturation) + 40%)", + "--w-color-critical-200": "hsl(var(--w-color-critical-200-hue) var(--w-color-critical-200-saturation) var(--w-color-critical-200-lightness))", + "--w-color-critical-200-hue": "0", + "--w-color-critical-200-lightness": "54%", + "--w-color-critical-200-saturation": "58%", + "--w-color-critical-50": "hsl(var(--w-color-critical-50-hue) var(--w-color-critical-50-saturation) var(--w-color-critical-50-lightness))", + "--w-color-critical-50-hue": "var(--w-color-critical-200-hue)", + "--w-color-critical-50-lightness": "calc(var(--w-color-critical-200-lightness) + 41%)", + "--w-color-critical-50-saturation": "calc(var(--w-color-critical-200-saturation) + 25%)", + "--w-color-grey-100": "hsl(var(--w-color-grey-100-hue) var(--w-color-grey-100-saturation) var(--w-color-grey-100-lightness))", + "--w-color-grey-100-hue": "var(--w-color-grey-600-hue)", + "--w-color-grey-100-lightness": "calc(var(--w-color-grey-600-lightness) + 73%)", + "--w-color-grey-100-saturation": "var(--w-color-grey-600-saturation)", + "--w-color-grey-200": "hsl(var(--w-color-grey-200-hue) var(--w-color-grey-200-saturation) var(--w-color-grey-200-lightness))", + "--w-color-grey-200-hue": "var(--w-color-grey-600-hue)", + "--w-color-grey-200-lightness": "calc(var(--w-color-grey-600-lightness) + 42%)", + "--w-color-grey-200-saturation": "var(--w-color-grey-600-saturation)", + "--w-color-grey-400": "hsl(var(--w-color-grey-400-hue) var(--w-color-grey-400-saturation) var(--w-color-grey-400-lightness))", + "--w-color-grey-400-hue": "var(--w-color-grey-600-hue)", + "--w-color-grey-400-lightness": "calc(var(--w-color-grey-600-lightness) + 21%)", + "--w-color-grey-400-saturation": "var(--w-color-grey-600-saturation)", + "--w-color-grey-50": "hsl(var(--w-color-grey-50-hue) var(--w-color-grey-50-saturation) var(--w-color-grey-50-lightness))", + "--w-color-grey-50-hue": "calc(var(--w-color-grey-600-hue) + 240)", + "--w-color-grey-50-lightness": "calc(var(--w-color-grey-600-lightness) + 82%)", + "--w-color-grey-50-saturation": "calc(var(--w-color-grey-600-saturation) + 12%)", + "--w-color-grey-600": "hsl(var(--w-color-grey-600-hue) var(--w-color-grey-600-saturation) var(--w-color-grey-600-lightness))", + "--w-color-grey-600-hue": "0", + "--w-color-grey-600-lightness": "15%", + "--w-color-grey-600-saturation": "0%", + "--w-color-info-100": "hsl(var(--w-color-info-100-hue) var(--w-color-info-100-saturation) var(--w-color-info-100-lightness))", + "--w-color-info-100-hue": "194", + "--w-color-info-100-lightness": "36%", + "--w-color-info-100-saturation": "66%", + "--w-color-info-50": "hsl(var(--w-color-info-50-hue) var(--w-color-info-50-saturation) var(--w-color-info-50-lightness))", + "--w-color-info-50-hue": "calc(var(--w-color-info-100-hue) + 2)", + "--w-color-info-50-lightness": "calc(var(--w-color-info-100-lightness) + 58%)", + "--w-color-info-50-saturation": "calc(var(--w-color-info-100-saturation) + 15%)", + "--w-color-positive-100": "hsl(var(--w-color-positive-100-hue) var(--w-color-positive-100-saturation) var(--w-color-positive-100-lightness))", + "--w-color-positive-100-hue": "162", + "--w-color-positive-100-lightness": "32%", + "--w-color-positive-100-saturation": "66%", + "--w-color-positive-50": "hsl(var(--w-color-positive-50-hue) var(--w-color-positive-50-saturation) var(--w-color-positive-50-lightness))", + "--w-color-positive-50-hue": "calc(var(--w-color-positive-100-hue) + 2)", + "--w-color-positive-50-lightness": "calc(var(--w-color-positive-100-lightness) + 61%)", + "--w-color-positive-50-saturation": "calc(var(--w-color-positive-100-saturation) + 11%)", + "--w-color-primary": "hsl(var(--w-color-primary-hue) var(--w-color-primary-saturation) var(--w-color-primary-lightness))", + "--w-color-primary-200": "hsl(var(--w-color-primary-200-hue) var(--w-color-primary-200-saturation) var(--w-color-primary-200-lightness))", + "--w-color-primary-200-hue": "var(--w-color-primary-hue)", + "--w-color-primary-200-lightness": "calc(var(--w-color-primary-lightness) - 5%)", + "--w-color-primary-200-saturation": "var(--w-color-primary-saturation)", + "--w-color-primary-hue": "254", + "--w-color-primary-lightness": "25%", + "--w-color-primary-saturation": "50%", + "--w-color-secondary": "hsl(var(--w-color-secondary-hue) var(--w-color-secondary-saturation) var(--w-color-secondary-lightness))", + "--w-color-secondary-100": "hsl(var(--w-color-secondary-100-hue) var(--w-color-secondary-100-saturation) var(--w-color-secondary-100-lightness))", + "--w-color-secondary-100-hue": "var(--w-color-secondary-hue)", + "--w-color-secondary-100-lightness": "calc(var(--w-color-secondary-lightness) + 10%)", + "--w-color-secondary-100-saturation": "var(--w-color-secondary-saturation)", + "--w-color-secondary-400": "hsl(var(--w-color-secondary-400-hue) var(--w-color-secondary-400-saturation) var(--w-color-secondary-400-lightness))", + "--w-color-secondary-400-hue": "calc(var(--w-color-secondary-hue) + 2)", + "--w-color-secondary-400-lightness": "calc(var(--w-color-secondary-lightness) - 7%)", + "--w-color-secondary-400-saturation": "var(--w-color-secondary-saturation)", + "--w-color-secondary-50": "hsl(var(--w-color-secondary-50-hue) var(--w-color-secondary-50-saturation) var(--w-color-secondary-50-lightness))", + "--w-color-secondary-50-hue": "var(--w-color-secondary-hue)", + "--w-color-secondary-50-lightness": "calc(var(--w-color-secondary-lightness) + 72%)", + "--w-color-secondary-50-saturation": "calc(var(--w-color-secondary-saturation) - 37%)", + "--w-color-secondary-600": "hsl(var(--w-color-secondary-600-hue) var(--w-color-secondary-600-saturation) var(--w-color-secondary-600-lightness))", + "--w-color-secondary-600-hue": "calc(var(--w-color-secondary-hue) + 2)", + "--w-color-secondary-600-lightness": "calc(var(--w-color-secondary-lightness) - 11%)", + "--w-color-secondary-600-saturation": "var(--w-color-secondary-saturation)", + "--w-color-secondary-hue": "180", + "--w-color-secondary-lightness": "25%", + "--w-color-secondary-saturation": "100%", + "--w-color-warning-100": "hsl(var(--w-color-warning-100-hue) var(--w-color-warning-100-saturation) var(--w-color-warning-100-lightness))", + "--w-color-warning-100-hue": "40", + "--w-color-warning-100-lightness": "49%", + "--w-color-warning-100-saturation": "100%", + "--w-color-warning-50": "hsl(var(--w-color-warning-50-hue) var(--w-color-warning-50-saturation) var(--w-color-warning-50-lightness))", + "--w-color-warning-50-hue": "calc(var(--w-color-warning-100-hue) - 3)", + "--w-color-warning-50-lightness": "calc(var(--w-color-warning-100-lightness) + 42%)", + "--w-color-warning-50-saturation": "calc(var(--w-color-warning-100-saturation) - 21%)", + "--w-color-white": "hsl(var(--w-color-white-hue) var(--w-color-white-saturation) var(--w-color-white-lightness))", + "--w-color-white-hue": "0", + "--w-color-white-lightness": "100%", + "--w-color-white-saturation": "0%", + } + `); + }); +}); diff --git a/client/src/tokens/colors.js b/client/src/tokens/colors.js index 7c758b445b..a1d84ae621 100644 --- a/client/src/tokens/colors.js +++ b/client/src/tokens/colors.js @@ -19,8 +19,10 @@ const colors = { black: { DEFAULT: { hex: '#000000', + hsl: 'hsl(0, 0%, 0%)', bgUtility: 'w-bg-black', textUtility: 'w-text-black', + cssVariable: '--w-color-black', usage: 'Shadows only', contrastText: 'white', }, @@ -28,36 +30,46 @@ const colors = { grey: { 600: { hex: '#262626', + hsl: 'hsl(0, 0%, 15%)', bgUtility: 'w-bg-grey-600', textUtility: 'w-text-grey-600', + cssVariable: '--w-color-grey-600', usage: 'Body copy, user content', contrastText: 'white', }, 400: { hex: '#5C5C5C', + hsl: 'hsl(0, 0%, 36%)', bgUtility: 'w-bg-grey-400', textUtility: 'w-text-grey-400', + cssVariable: '--w-color-grey-400', usage: 'Help text, placeholders, meta text, neutral state indicators', contrastText: 'white', }, 200: { hex: '#929292', + hsl: 'hsl(0, 0%, 57%)', bgUtility: 'w-bg-grey-200', textUtility: 'w-text-grey-200', + cssVariable: '--w-color-grey-200', usage: 'Dividers, button borders', contrastText: 'primary', }, 100: { hex: '#E0E0E0', + hsl: 'hsl(0, 0%, 88%)', bgUtility: 'w-bg-grey-100', textUtility: 'w-text-grey-100', + cssVariable: '--w-color-grey-100', usage: 'Dividers, field borders, panel borders', contrastText: 'primary', }, 50: { hex: '#F6F6F8', + hsl: 'hsl(240, 12%, 97%)', bgUtility: 'w-bg-grey-50', textUtility: 'w-text-grey-50', + cssVariable: '--w-color-grey-50', usage: 'Background for panels, row highlights', contrastText: 'primary', }, @@ -65,61 +77,77 @@ const colors = { white: { DEFAULT: { hex: '#FFFFFF', + hsl: 'hsl(0, 0%, 100%)', bgUtility: 'w-bg-white', textUtility: 'w-text-white', + cssVariable: '--w-color-white', usage: 'Page backgrounds, Panels, Button text', contrastText: 'primary', }, }, - teal: { + secondary: { 600: { hex: '#004345', - bgUtility: 'w-bg-teal-600', - textUtility: 'w-text-teal-600', + hsl: 'hsl(182, 100%, 14%)', + bgUtility: 'w-bg-secondary-600', + textUtility: 'w-text-secondary-600', + cssVariable: '--w-color-secondary-600', usage: 'Hover states for two-tone buttons', contrastText: 'white', }, 400: { hex: '#005B5E', - bgUtility: 'w-bg-teal-400', - textUtility: 'w-text-teal-400', + hsl: 'hsl(182, 100%, 18%)', + bgUtility: 'w-bg-secondary-400', + textUtility: 'w-text-secondary-400', + cssVariable: '--w-color-secondary-400', usage: 'Two-tone buttons, hover states', contrastText: 'white', }, - 200: { + DEFAULT: { hex: '#007D7E', - bgUtility: 'w-bg-teal-200', - textUtility: 'w-text-teal-200', + hsl: 'hsl(180, 100%, 25%)', + bgUtility: 'w-bg-secondary', + textUtility: 'w-text-secondary', + cssVariable: '--w-color-secondary', usage: 'Primary buttons, action links', contrastText: 'white', }, 100: { hex: '#00B0B1', - bgUtility: 'w-bg-teal-100', - textUtility: 'w-text-teal-100', + hsl: 'hsl(180, 100%, 35%)', + bgUtility: 'w-bg-secondary-100', + textUtility: 'w-text-secondary-100', + cssVariable: '--w-color-secondary-100', usage: 'UI element highlights', contrastText: 'white', }, 50: { hex: '#F2FCFC', - bgUtility: 'w-bg-teal-50', - textUtility: 'w-text-teal-50', + hsl: 'hsl(180, 63%, 97%)', + bgUtility: 'w-bg-secondary-50', + textUtility: 'w-text-secondary-50', + cssVariable: '--w-color-secondary-50', usage: 'Button backgrounds, highlighted fields background', - contrastText: 'teal-200', + contrastText: 'secondary', }, }, primary: { DEFAULT: { hex: '#2E1F5E', + hsl: 'hsl(254, 50%, 25%)', bgUtility: 'w-bg-primary', textUtility: 'w-text-primary', + cssVariable: '--w-color-primary', usage: 'Wagtail branding, Panels, Headings, Buttons, Labels', contrastText: 'white', }, 200: { hex: '#261A4E', + hsl: 'hsl(254, 50%, 20%)', bgUtility: 'w-bg-primary-200', textUtility: 'w-text-primary-200', + cssVariable: '--w-color-primary-200', usage: 'Accent for elements used in conjunction with primary colour in sidebar', contrastText: 'white', @@ -128,15 +156,19 @@ const colors = { info: { 100: { hex: '#1F7E9A', + hsl: 'hsl(194, 66%, 36%)', bgUtility: 'w-bg-info-100', textUtility: 'w-text-info-100', + cssVariable: '--w-color-info-100', usage: 'Background and icons for information messages', contrastText: 'white', }, 50: { hex: '#E2F5FC', + hsl: 'hsl(196, 81%, 94%)', bgUtility: 'w-bg-info-50', textUtility: 'w-text-info-50', + cssVariable: '--w-color-info-50', usage: 'Background only, for information messages', contrastText: 'primary', }, @@ -144,15 +176,19 @@ const colors = { positive: { 100: { hex: '#1B8666', + hsl: 'hsl(162, 66%, 32%)', bgUtility: 'w-bg-positive-100', textUtility: 'w-text-positive-100', + cssVariable: '--w-color-positive-100', usage: 'Positive states', contrastText: 'white', }, 50: { hex: '#E0FBF4', + hsl: 'hsl(164, 77%, 93%)', bgUtility: 'w-bg-positive-50', textUtility: 'w-text-positive-50', + cssVariable: '--w-color-positive-50', usage: 'Background only, for positive states', contrastText: 'primary', }, @@ -160,15 +196,19 @@ const colors = { warning: { 100: { hex: '#FAA500', + hsl: 'hsl(40, 100%, 49%)', bgUtility: 'w-bg-warning-100', textUtility: 'w-text-warning-100', + cssVariable: '--w-color-warning-100', usage: 'Background and icons for potentially dangerous states', contrastText: 'primary', }, 50: { hex: '#FAECD5', + hsl: 'hsl(37, 79%, 91%)', bgUtility: 'w-bg-warning-50', textUtility: 'w-text-warning-50', + cssVariable: '--w-color-warning-50', usage: 'Background only, for potentially dangerous states', contrastText: 'primary', }, @@ -176,22 +216,28 @@ const colors = { critical: { 200: { hex: '#CD4444', + hsl: 'hsl(0, 58%, 54%)', bgUtility: 'w-bg-critical-200', textUtility: 'w-text-critical-200', + cssVariable: '--w-color-critical-200', usage: 'Dangerous actions or states (over light background), errors', contrastText: 'white', }, 100: { hex: '#FD5765', + hsl: 'hsl(355, 98%, 67%)', bgUtility: 'w-bg-critical-100', textUtility: 'w-text-critical-100', + cssVariable: '--w-color-critical-100', usage: 'Dangerous actions or states (over dark background)', contrastText: 'primary', }, 50: { hex: '#FDE9E9', + hsl: 'hsl(0, 83%, 95%)', bgUtility: 'w-bg-critical-50', textUtility: 'w-text-critical-50', + cssVariable: '--w-color-critical-50', usage: 'Background only, for dangerous states', contrastText: 'primary', }, diff --git a/client/tailwind.config.js b/client/tailwind.config.js index 545681b0c6..168c9aa8be 100644 --- a/client/tailwind.config.js +++ b/client/tailwind.config.js @@ -4,6 +4,7 @@ const vanillaRTL = require('tailwindcss-vanilla-rtl'); * Design Tokens */ const colors = require('./src/tokens/colors'); +const { generateColorVariables } = require('./src/tokens/colorVariables'); const { fontFamily, fontSize, @@ -32,7 +33,10 @@ const scrollbarThin = require('./src/plugins/scrollbarThin'); const themeColors = Object.fromEntries( Object.entries(colors).map(([key, hues]) => { const shades = Object.fromEntries( - Object.entries(hues).map(([k, shade]) => [k, shade.hex]), + Object.entries(hues).map(([k, shade]) => [ + k, + `var(${shade.cssVariable})`, + ]), ); return [key, shades]; }), @@ -49,12 +53,22 @@ module.exports = { }, colors: { ...themeColors, - inherit: 'inherit', - current: 'currentColor', - transparent: 'transparent', + // Fades of white and black are not customisable. + 'white-15': 'rgba(255, 255, 255, 0.15)', + 'white-50': 'rgba(255, 255, 255, 0.50)', + 'white-80': 'rgba(255, 255, 255, 0.80)', + 'white-85': 'rgba(255, 255, 255, 0.85)', + 'black-10': 'rgba(0, 0, 0, 0.10)', + 'black-20': 'rgba(0, 0, 0, 0.20)', + 'black-35': 'rgba(0, 0, 0, 0.35)', + 'black-50': 'rgba(0, 0, 0, 0.50)', + // Color keywords. + 'inherit': 'inherit', + 'current': 'currentColor', + 'transparent': 'transparent', /* allow system colours https://www.w3.org/TR/css-color-4/#css-system-colors */ - LinkText: 'LinkText', - ButtonText: 'ButtonText', + 'LinkText': 'LinkText', + 'ButtonText': 'ButtonText', }, fontFamily: { sans: 'var(--w-font-sans)', @@ -135,6 +149,7 @@ module.exports = { ':root': { '--w-font-sans': fontFamily.sans.join(', '), '--w-font-mono': fontFamily.mono.join(', '), + ...generateColorVariables(colors), }, }); }),