summaryrefslogtreecommitdiffstats
path: root/toolkit/themes/shared/design-system/tokens-config.js
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/themes/shared/design-system/tokens-config.js')
-rw-r--r--toolkit/themes/shared/design-system/tokens-config.js547
1 files changed, 547 insertions, 0 deletions
diff --git a/toolkit/themes/shared/design-system/tokens-config.js b/toolkit/themes/shared/design-system/tokens-config.js
new file mode 100644
index 0000000000..81d92d8faa
--- /dev/null
+++ b/toolkit/themes/shared/design-system/tokens-config.js
@@ -0,0 +1,547 @@
+/* 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, platform }) => {
+ 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 && !platform
+ ? `@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");
+}
+
+// Easy way to grab variable values later for display.
+let variableLookupTable = {};
+
+function storybookJSFormat(args) {
+ let dictionary = Object.assign({}, args.dictionary);
+ let resolvedTokens = dictionary.allTokens.map(token => {
+ let tokenVal = resolveReferences(dictionary, token.original);
+ return {
+ name: token.name,
+ ...tokenVal,
+ };
+ });
+ dictionary.allTokens = dictionary.allProperties = resolvedTokens;
+
+ let parsedData = JSON.parse(
+ formatBaseTokenNames(
+ StyleDictionary.format["javascript/module-flat"]({
+ ...args,
+ dictionary,
+ })
+ )
+ .trim()
+ .replaceAll(/(^module\.exports\s*=\s*|\;$)/g, "")
+ );
+ let storybookTables = formatTokensTablesData(parsedData);
+
+ return `${customFileHeader({ platform: "storybook" })}
+ export const storybookTables = ${JSON.stringify(storybookTables)};
+
+ export const variableLookupTable = ${JSON.stringify(variableLookupTable)};
+ `;
+}
+
+function resolveReferences(dictionary, originalVal) {
+ let resolvedValues = {};
+ Object.entries(originalVal).forEach(([key, value]) => {
+ if (typeof value === "object" && value != null) {
+ resolvedValues[key] = resolveReferences(dictionary, value);
+ } else {
+ let resolvedVal = getValueWithReferences(dictionary, value);
+ resolvedValues[key] = resolvedVal;
+ }
+ });
+ return resolvedValues;
+}
+
+function getValueWithReferences(dictionary, value) {
+ let valWithRefs = value;
+ if (dictionary.usesReference(value)) {
+ dictionary.getReferences(value).forEach(ref => {
+ valWithRefs = valWithRefs.replace(
+ `{${ref.path.join(".")}}`,
+ `var(--${ref.name})`
+ );
+ });
+ }
+ return valWithRefs;
+}
+
+function formatTokensTablesData(tokensData) {
+ let tokensTables = {};
+ Object.entries(tokensData).forEach(([key, value]) => {
+ variableLookupTable[key] = value;
+ let formattedToken = {
+ value,
+ name: `--${key}`,
+ };
+
+ let tableName = getTableName(key);
+ if (tokensTables[tableName]) {
+ tokensTables[tableName].push(formattedToken);
+ } else {
+ tokensTables[tableName] = [formattedToken];
+ }
+ });
+ return tokensTables;
+}
+
+const SINGULAR_TABLE_CATEGORIES = [
+ "button",
+ "color",
+ "link",
+ "size",
+ "space",
+ "opacity",
+ "outline",
+ "padding",
+ "margin",
+];
+
+function getTableName(tokenName) {
+ let replacePattern = /^(button-|input-text-|focus-|checkbox-)/;
+ if (tokenName.match(replacePattern)) {
+ tokenName = tokenName.replace(replacePattern, "");
+ }
+ let [category, type] = tokenName.split("-");
+ return SINGULAR_TABLE_CATEGORIES.includes(category) || !type
+ ? category
+ : `${category}-${type}`;
+}
+
+module.exports = {
+ source: ["design-tokens.json"],
+ format: {
+ "css/variables/shared": createDesktopFormat(),
+ "css/variables/brand": createDesktopFormat("brand"),
+ "css/variables/platform": createDesktopFormat("platform"),
+ "javascript/storybook": storybookJSFormat,
+ },
+ 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,
+ },
+ ],
+ },
+ storybook: {
+ options: {
+ outputReferences: true,
+ showFileHeader: false,
+ },
+ transforms: [
+ ...StyleDictionary.transformGroup.css,
+ ...["shared", "platform", "brand"].map(createLightDarkTransform),
+ ],
+ files: [
+ {
+ destination: "tokens-storybook.mjs",
+ format: "javascript/storybook",
+ },
+ ],
+ },
+ },
+};