fune/toolkit/themes/shared/design-system/tokens-config.js
Mark Striemer 68b2a33f07 Bug 1850611 - Create a JSON file source of truth for our design tokens. r=reusable-components-reviewers,desktop-theme-reviewers,hjones,dao
* Add light-dark transformer for generating web CSS
* Use value object in design-tokens.json
* Add HCM media queries to built CSS
* Add MPL license and how to edit file header
* Strip '-default' from token names and values
* Refactor generated media query placement within file.
* generate multiple CSS files from a single JSON file.
* add the :host(.anonymous-content-host) selector to the built CSS
* Output tokens in pre-defined order
* Generate CSS layer declarations and relevant selectors
* Sort tokens by t-shirt size and state semantically not alphabetically
* Add remaining tokens to design-tokens.json
* Add design tokens JSON docs

---------

Co-authored-by: Jules Simplicio <jsimplicio@mozilla.com>
Co-authored-by: Hanna Jones <hjones@mozilla.com>
Co-authored-by: Mark Striemer <mstriemer@mozilla.com>
Co-authored-by: Tim Giles <tgiles@mozilla.com>

Differential Revision: https://phabricator.services.mozilla.com/D204108
2024-03-26 16:56:06 +00:00

428 lines
12 KiB
JavaScript

/* 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 { createPropertyFormatter } = StyleDictionary.formatHelpers;
const TOKEN_SECTIONS = {
"Attention Dot": "attention-dot",
"Background Color": "background-color",
Border: "border",
"Box Shadow": "box-shadow",
Button: "button",
Checkbox: "checkbox",
Color: ["brand-color", "color", "platform-color"],
"Focus Outline": "focus-outline",
"Font Size": "font-size",
"Font Weight": "font-weight",
Icon: "icon",
"Input - Text": "input-text",
Link: "link",
"Outline Color": "outline-color",
Size: "size",
Space: "space",
Text: "text",
Unspecified: "",
};
const TSHIRT_ORDER = [
"circle",
"xxxsmall",
"xxsmall",
"xsmall",
"small",
"medium",
"large",
"xlarge",
"xxlarge",
"xxxlarge",
];
const STATE_ORDER = [
"base",
"default",
"root",
"hover",
"active",
"focus",
"disabled",
];
/**
* Adds the Mozilla Public License header in one comment and
* how to make changes in the generated output files via the
* design-tokens.json file in another comment. Also imports
* tokens-shared.css when applicable.
*
* @param {string} surface
* Desktop surface, either "brand" or "platform". Determines
* whether or not we need to import tokens-shared.css.
* @returns {string} Formatted comment header string
*/
let customFileHeader = surface => {
let licenseString = [
"/* 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/. */",
].join("\n");
let commentString = [
"/* DO NOT EDIT this file directly, instead modify design-tokens.json",
" * and run `npm run build` to see your changes. */",
].join("\n");
let cssImport = surface
? `@import url("chrome://global/skin/design-system/tokens-shared.css");\n\n`
: "";
let layerString = !surface
? `@layer tokens-foundation, tokens-prefers-contrast, tokens-forced-colors;\n\n`
: "";
return [
licenseString + "\n\n" + commentString + "\n\n" + cssImport + layerString,
];
};
const NEST_MEDIA_QUERIES_COMMENT = `/* Bug 1879900: Can't nest media queries inside of :host, :root selector
until Bug 1879349 lands */`;
const MEDIA_QUERY_PROPERTY_MAP = {
"forced-colors": "forcedColors",
"prefers-contrast": "prefersContrast",
};
function formatBaseTokenNames(str) {
return str.replaceAll(/(?<tokenName>\w+)-base(?=\b)/g, "$<tokenName>");
}
/**
* Creates a surface-specific formatter. The formatter is used to build
* our different CSS files, including "prefers-contrast" and "forced-colors"
* media queries. See more at
* https://amzn.github.io/style-dictionary/#/formats?id=formatter
*
* @param {string} surface
* Which desktop area we are generating CSS for.
* Either "brand" (i.e. in-content) or "platform" (i.e. chrome).
* @returns {Function} - Formatter function that returns a CSS string.
*/
const createDesktopFormat = surface => args => {
return formatBaseTokenNames(
customFileHeader(surface) +
formatTokens({
surface,
args,
}) +
formatTokens({
mediaQuery: "prefers-contrast",
surface,
args,
}) +
formatTokens({
mediaQuery: "forced-colors",
surface,
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 {string} [tokenArgs.surface]
* Specifies a desktop surface, either "brand" or "platform".
* @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, surface, args }) {
let prop = MEDIA_QUERY_PROPERTY_MAP[mediaQuery] ?? "default";
let dictionary = Object.assign({}, args.dictionary);
let tokens = [];
dictionary.allTokens.forEach(token => {
let originalVal = getOriginalTokenValue(token, prop, surface);
if (originalVal != undefined) {
let formattedToken = transformTokenValue(token, originalVal, dictionary);
tokens.push(formattedToken);
}
});
if (!tokens.length) {
return "";
}
dictionary.allTokens = dictionary.allProperties = tokens;
let formattedVars = formatVariables({
format: "css",
dictionary,
outputReferences: args.options.outputReferences,
formatting: {
indentation: mediaQuery ? " " : " ",
},
});
let layer = `tokens-${mediaQuery ?? "foundation"}`;
// Weird spacing below is unfortunately necessary for formatting the built CSS.
if (mediaQuery) {
return `
${NEST_MEDIA_QUERIES_COMMENT}
@layer ${layer} {
@media (${mediaQuery}) {
:root,
:host(.anonymous-content-host) {
${formattedVars}
}
}
}
`;
}
return `@layer ${layer} {
:root,
:host(.anonymous-content-host) {
${formattedVars}
}
}
`;
}
/**
* Finds the original value of a token for a given media query and surface.
*
* @param {object} token - Token object parsed by style-dictionary.
* @param {string} prop - Name of the property we're querying for.
* @param {string} surface
* The desktop surface we're generating CSS for, either "brand" or "platform".
* @returns {string} The original token value based on our parameters.
*/
function getOriginalTokenValue(token, prop, surface) {
if (surface) {
return token.original.value[surface]?.[prop];
} else if (prop == "default" && typeof token.original.value != "object") {
return token.original.value;
}
return token.original.value?.[prop];
}
/**
* Updates a token's value to the relevant original value after resolving
* variable references.
*
* @param {object} token - Token object parsed from JSON by style-dictionary.
* @param {string} originalVal
* Original value of the token for the combination of surface and media query.
* @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, originalVal, dictionary) {
let value = originalVal;
if (dictionary.usesReference(value)) {
dictionary.getReferences(value).forEach(ref => {
value = value.replace(`{${ref.path.join(".")}}`, `var(--${ref.name})`);
});
}
return { ...token, value };
}
/**
* Creates a light-dark transform that works for a given surface. Registers
* the transform with style-dictionary and returns the transform's name.
*
* @param {string} surface
* The desktop surface we're generating CSS for, either "brand", "platform",
* or "shared".
* @returns {string} Name of the transform that was registered.
*/
const createLightDarkTransform = surface => {
let name = `lightDarkTransform/${surface}`;
// Matcher function for determining if a token's value needs to undergo
// a light-dark transform.
let matcher = token => {
if (surface != "shared") {
return (
token.original.value[surface]?.light &&
token.original.value[surface]?.dark
);
}
return token.original.value.light && token.original.value.dark;
};
// Function that uses the token's original value to create a new "default"
// light-dark value and updates the original value object.
let transformer = token => {
if (surface != "shared") {
let lightDarkVal = `light-dark(${token.original.value[surface].light}, ${token.original.value[surface].dark})`;
token.original.value[surface].default = lightDarkVal;
return token.value;
}
let value = `light-dark(${token.original.value.light}, ${token.original.value.dark})`;
token.original.value.default = value;
return value;
};
StyleDictionary.registerTransform({
type: "value",
transitive: true,
name,
matcher,
transformer,
});
return name;
};
/**
* Format the tokens dictionary to a string. This mostly defers to
* StyleDictionary.createPropertyFormatter but first it sorts the tokens based
* on the groupings in TOKEN_SECTIONS and adds comment headers to CSS output.
*
* @param {object} options
* Options for tokens to format.
* @param {string} options.format
* The format to output. Supported: "css"
* @param {object} options.dictionary
* The tokens dictionary.
* @param {string} options.outputReferences
* Whether to output variable references.
* @param {object} options.formatting
* The formatting settings to be passed to createPropertyFormatter.
* @returns {string} The formatted tokens.
*/
function formatVariables({ format, dictionary, outputReferences, formatting }) {
let lastSection = [];
let propertyFormatter = createPropertyFormatter({
outputReferences,
dictionary,
format,
formatting,
});
let outputParts = [];
let remainingTokens = [...dictionary.allTokens];
let isFirst = true;
function tokenParts(name) {
let lastDash = name.lastIndexOf("-");
let suffix = name.substring(lastDash + 1);
if (TSHIRT_ORDER.includes(suffix) || STATE_ORDER.includes(suffix)) {
return [name.substring(0, lastDash), suffix];
}
return [name, ""];
}
for (let [label, selector] of Object.entries(TOKEN_SECTIONS)) {
let sectionMatchers = Array.isArray(selector) ? selector : [selector];
let sectionParts = [];
remainingTokens = remainingTokens.filter(token => {
if (
sectionMatchers.some(m =>
m.test ? m.test(token.name) : token.name.startsWith(m)
)
) {
sectionParts.push(token);
return false;
}
return true;
});
if (sectionParts.length) {
sectionParts.sort((a, b) => {
let aName = formatBaseTokenNames(a.name);
let bName = formatBaseTokenNames(b.name);
let [aToken, aSuffix] = tokenParts(aName);
let [bToken, bSuffix] = tokenParts(bName);
if (aSuffix || bSuffix) {
if (aToken == bToken) {
let aSize = TSHIRT_ORDER.indexOf(aSuffix);
let bSize = TSHIRT_ORDER.indexOf(bSuffix);
if (aSize != -1 && bSize != -1) {
return aSize - bSize;
}
let aState = STATE_ORDER.indexOf(aSuffix);
let bState = STATE_ORDER.indexOf(bSuffix);
if (aState != -1 && bState != -1) {
return aState - bState;
}
}
}
return aToken.localeCompare(bToken, undefined, { numeric: true });
});
let headingParts = [];
if (!isFirst) {
headingParts.push("");
}
isFirst = false;
let sectionLevel = "**";
let labelParts = label.split("/");
for (let i = 0; i < labelParts.length; i++) {
if (labelParts[i] != lastSection[i]) {
headingParts.push(
`${formatting.indentation}/${sectionLevel} ${labelParts[i]} ${sectionLevel}/`
);
}
sectionLevel += "*";
}
lastSection = labelParts;
outputParts = outputParts.concat(
headingParts.concat(sectionParts.map(propertyFormatter))
);
}
}
return outputParts.join("\n");
}
module.exports = {
source: ["design-tokens.json"],
format: {
"css/variables/shared": createDesktopFormat(),
"css/variables/brand": createDesktopFormat("brand"),
"css/variables/platform": createDesktopFormat("platform"),
},
platforms: {
css: {
options: {
outputReferences: true,
showFileHeader: false,
},
transforms: [
...StyleDictionary.transformGroup.css,
...["shared", "platform", "brand"].map(createLightDarkTransform),
],
files: [
{
destination: "tokens-shared.css",
format: "css/variables/shared",
},
{
destination: "tokens-brand.css",
format: "css/variables/brand",
filter: token =>
typeof token.original.value == "object" &&
token.original.value.brand,
},
{
destination: "tokens-platform.css",
format: "css/variables/platform",
filter: token =>
typeof token.original.value == "object" &&
token.original.value.platform,
},
],
},
},
};