Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FIDEFE-4626 - Add HCM media queries to built CSS #3

Merged
merged 3 commits into from
Jan 29, 2024
Merged
Changes from 1 commit
Commits
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
30 changes: 26 additions & 4 deletions toolkit/themes/shared/design-system/build/css/tokens-shared.css
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
:root {
--text-deemphasized: color-mix(in srgb, currentColor 60%, transparent);
--text-platform: currentColor;
--text-color: CanvasText;
--text-color-deemphasized: color-mix(in srgb, currentColor 60%, transparent);
--color-white: #ffffff;
--color-yellow-05: #ffebcd;
--color-yellow-80: #5a3100;
@@ -32,9 +30,33 @@
--color-blue-50: #0060df;
--color-blue-30: #73a7f3;
--color-black-a10: rgba(0, 0, 0, 0.1);
--text-brand: light-dark(var(--color-gray-100), var(--color-gray-05));
--border-width-default: 1px;
--border-radius-medium: 8px;
--border-radius-small: 4px;
--border-radius-circle: 9999px;
--color-background-warning: light-dark(var(--color-yellow-05), var(--color-blue-80));
--color-background-success: light-dark(var(--color-green-05), var(--color-yellow-80));
--color-background-information: light-dark(var(--color-blue-05), var(--color-blue-80));
--color-background-critical: light-dark(var(--color-red-05), var(--color-red-80));
}

@media (prefers-contrast) {
:root {
--text-color-deemphasized: inherit;
--text-color-default: CanvasText;
--border-interactive-color-disabled: GrayText;
--border-interactive-color-active: AccentColor;
--border-interactive-color-hover: SelectedItem;
--border-interactive-color-default: AccentColor;
--border-color-default: var(--text-color-default);
}
}

@media (forced-colors) {
:root {
--border-interactive-color-disabled: GrayText;
--border-interactive-color-active: ButtonText;
--border-interactive-color-hover: ButtonText;
--border-interactive-color-default: ButtonText;
}
}
99 changes: 96 additions & 3 deletions toolkit/themes/shared/design-system/config.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,101 @@

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

/* eslint-env node */

const StyleDictionary = require("style-dictionary");
const { formattedVariables } = StyleDictionary.formatHelpers;

const MEDIA_QUERY_PROPERTY_MAP = {
"forced-colors": "forcedColors",
"prefers-contrast": "prefersContrast",
};

/**
* Formats built CSS to include "prefers-contrast" and "forced-colors" media
* queries.
*
* @param {object} args
* Formatter arguments provided by style-dictionary. See more at
* https://amzn.github.io/style-dictionary/#/formats?id=formatter
* @returns {string}
* Formatted CSS including media queries.
*/
function hcmFormatter(args) {
return (
formatTokens({ args }) +
formatTokens({ mediaQuery: "prefers-contrast", args }) +
formatTokens({ mediaQuery: "forced-colors", args })
);
}

/**
* Formats a subset of tokens into CSS. Wraps token CSS in a media query when
* applicable.
*
* @param {object} tokenArgs
* @param {string} [tokenArgs.mediaQuery]
* Media query formatted CSS should be wrapped in. This is used
* to determine what property we are parsing from the token values.
* @param {object} tokenArgs.args
* Formatter arguments provided by style-dictionary. See more at
* https://amzn.github.io/style-dictionary/#/formats?id=formatter
* @returns {string} Tokens formatted into a CSS string.
*/
function formatTokens({ mediaQuery, args }) {
let prop = MEDIA_QUERY_PROPERTY_MAP[mediaQuery] ?? "value";
let dictionary = Object.assign({}, args.dictionary);
let tokens = [];

dictionary.allTokens.forEach(token => {
let value = token[prop] || token.original.value[prop]
if (value && typeof value !== "object") {
let formattedToken = transformTokenValue(token, prop, dictionary);
tokens.push(formattedToken);
}
});

dictionary.allTokens = dictionary.allProperties = tokens;

let formattedVars = formattedVariables({
format: "css",
hannajones marked this conversation as resolved.
Show resolved Hide resolved
dictionary,
outputReferences: args.options.outputReferences,
});

if (mediaQuery) {
return `
@media (${mediaQuery}) {
:root {
${formattedVars.split("\n").join(`\n `)}
hannajones marked this conversation as resolved.
Show resolved Hide resolved
}
}
`;
}

return `:root {\n${formattedVars}\n}\n`;
}

/**
* Takes a token object and changes "value" based on the supplied prop. Also
* preserves variable references when necessary.
*
* @param {object} token - Token object parsed from JSON by style-dictionary.
* @param {string} prop
* Name of the property used to get the token's new value.
* @param {object} dictionary
* Object of transformed tokens and helper fns provided by style-dictionary.
* @returns {object} Token object with an updated value.
*/
function transformTokenValue(token, prop, dictionary) {
let originalVal = token.original.value[prop];
if (dictionary.usesReference(originalVal)) {
let refs = dictionary.getReferences(originalVal);
return { ...token, value: `var(--${refs[0].name})` };
hannajones marked this conversation as resolved.
Show resolved Hide resolved
}
return { ...token, value: token[prop] || originalVal};
}

module.exports = {
source: ["design-tokens.json"],
@@ -15,7 +105,7 @@ module.exports = {
transitive: true,
name: "defaultTransform",
matcher: token => token.original.value.default,
transformer: token => token.original.value.default
transformer: token => token.original.value.default,
},
lightDarkTransform: {
type: "value",
@@ -27,6 +117,9 @@ module.exports = {
},
},
},
format: {
"css/variables/hcm": hcmFormatter,
},
platforms: {
css: {
// The ordering of transforms matter, so if we encountered
@@ -42,7 +135,7 @@ module.exports = {
files: [
{
destination: "tokens-shared.css",
format: "css/variables",
format: "css/variables/hcm",
options: {
outputReferences: true,
showFileHeader: false,
76 changes: 61 additions & 15 deletions toolkit/themes/shared/design-system/design-tokens.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,55 @@
{
"border": {
"color": {
"default": {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left in the "default" name for now even though it sounds like we might not want that long term. If/when we decide on that we can have a separate task to handle this in a different way.

Copy link
Author

@hannajones hannajones Jan 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately it seems like we still need this even after switching to value as an object because we still have the same problem where style-dictionary doesn't parse token names like text-color-deemphasized if it's nested under text-color 😞

Strictly speaking the default here isn't necessary anymore, but I kept it for consistency for now

"value": {
"prefersContrast": "{text.color.default}"
}
}
},
"interactive": {
"color": {
"default": {
"value": {
"prefersContrast": "AccentColor",
"forcedColors": "ButtonText"
}
},
"hover": {
"value": {
"forcedColors": "ButtonText",
"prefersContrast": "SelectedItem"
}
},
"active": {
"value": {
"prefersContrast": "AccentColor",
"forcedColors": "ButtonText"
}
},
"disabled": {
"value": {
"prefersContrast": "GrayText",
"forcedColors": "GrayText"
}
}
}
},
"radius": {
"circle": {
"value": "9999px"
},
"small": {
"value": "4px"
},
"medium": {
"value": "8px"
}
},
"width": {
"value": "1px"
}
},
"color": {
"black": {
"a10": {
@@ -133,21 +184,16 @@
},
"text": {
"color": {
"value": "CanvasText"
},
"brand": {
"value": {
"light": "{color.gray.100}",
"dark": "{color.gray.05}"
}
},
"platform": {
"value": "currentColor"
},
"deemphasized": {
"value": {
"default": "color-mix(in srgb, currentColor 60%, transparent)",
"prefersContrast": "inherit"
"default": {
"value": {
"prefersContrast": "CanvasText"
}
},
"deemphasized": {
"value": {
"default": "color-mix(in srgb, currentColor 60%, transparent)",
"prefersContrast": "inherit"
}
}
}
}
84 changes: 64 additions & 20 deletions toolkit/themes/shared/design-system/tests/config.test.js
Original file line number Diff line number Diff line change
@@ -7,11 +7,15 @@ const config = require("../config");

const TEST_BUILD_PATH = "tests/build/css/";

const EXPECTED_CSS_RULES = {
"--color-background-critical": "light-dark(var(--color-red-05), var(--color-red-80))",
"--color-background-information": "light-dark(var(--color-blue-05), var(--color-blue-80))",
"--color-background-success": "light-dark(var(--color-green-05), var(--color-yellow-80))",
"--color-background-warning": "light-dark(var(--color-yellow-05), var(--color-blue-80))",
const BASE_CSS_RULES = {
"--color-background-critical":
"light-dark(var(--color-red-05), var(--color-red-80))",
"--color-background-information":
"light-dark(var(--color-blue-05), var(--color-blue-80))",
"--color-background-success":
"light-dark(var(--color-green-05), var(--color-yellow-80))",
"--color-background-warning":
"light-dark(var(--color-yellow-05), var(--color-blue-80))",
"--color-black-a10": "rgba(0, 0, 0, 0.1)",
"--color-blue-05": "#deeafc",
"--color-blue-30": "#73a7f3",
@@ -42,10 +46,35 @@ const EXPECTED_CSS_RULES = {
"--color-yellow-30": "#e49c49",
"--color-yellow-50": "#cd411e",
"--color-yellow-80": "#5a3100",
"--text-brand": "light-dark(var(--color-gray-100), var(--color-gray-05))",
"--text-color": "CanvasText",
"--text-deemphasized": "color-mix(in srgb, currentColor 60%, transparent)",
"--text-platform": "currentColor",
"--text-color-deemphasized":
"color-mix(in srgb, currentColor 60%, transparent)",
"--border-radius-circle": "9999px",
"--border-radius-small": "4px",
"--border-radius-medium": "8px",
"--border-width-default": "1px",
};

const PREFERS_CONTRAST_CSS_RULES = {
"--text-color-deemphasized": "inherit",
"--text-color-default": "CanvasText",
"--border-interactive-color-disabled": "GrayText",
"--border-interactive-color-active": "AccentColor",
"--border-interactive-color-hover": "SelectedItem",
"--border-interactive-color-default": "AccentColor",
"--border-color-default": "var(--text-color-default)",
};

const FORCED_COLORS_CSS_RULES = {
"--border-interactive-color-disabled": "GrayText",
"--border-interactive-color-active": "ButtonText",
"--border-interactive-color-hover": "ButtonText",
"--border-interactive-color-default": "ButtonText",
};

const FIXTURE_BY_QUERY = {
base: BASE_CSS_RULES,
"prefers-contrast": PREFERS_CONTRAST_CSS_RULES,
"forced-colors": FORCED_COLORS_CSS_RULES,
};

// Use our real config, just modify some values for the test.
@@ -56,21 +85,36 @@ testConfig.platforms.css.buildPath = TEST_BUILD_PATH;
describe("generated CSS", () => {
StyleDictionary.extend(testConfig).buildAllPlatforms();

describe("css/variables", () => {
describe("css/variables/hcm format", () => {
const output = fs.readFileSync(`${TEST_BUILD_PATH}tokens-shared.css`, {
encoding: "UTF-8",
});

it("should produce the expected CSS", () => {
let formattedCSS = output.split("\n").reduce((rulesObj, rule) => {
let [key, val] = rule.split(":");
if (key && val) {
return { ...rulesObj, [key.trim()]: val.trim().replace(";", "") };
}
return rulesObj;
}, {});

expect(formattedCSS).toMatchObject(EXPECTED_CSS_RULES);
let rulesByMediaQuery = output.split("@media");

it("should contain three blocks of CSS, including media queries", () => {
expect(rulesByMediaQuery.length).toBe(3);
expect(rulesByMediaQuery[1]).toEqual(
expect.stringContaining("prefers-contrast")
);
expect(rulesByMediaQuery[2]).toEqual(
expect.stringContaining("forced-colors")
);
});

rulesByMediaQuery.forEach(ruleSet => {
let queryName = ruleSet.trim().match(/(?<=\().+?(?=\) \{)/) || "base";
it(`should produce the expected ${queryName} CSS rules`, () => {
let formattedCSS = ruleSet.split("\n").reduce((rulesObj, rule) => {
let [key, val] = rule.split(":");
if (key.trim() && val) {
return { ...rulesObj, [key.trim()]: val.trim().replace(";", "") };
}
return rulesObj;
}, {});

expect(formattedCSS).toStrictEqual(FIXTURE_BY_QUERY[queryName]);
});
});
});
});