diff --git a/packages/cfpb-design-system/src/elements/abstracts/custom-props.css b/packages/cfpb-design-system/src/elements/abstracts/custom-props.css index cdfe3b6b02..af97ee8432 100644 --- a/packages/cfpb-design-system/src/elements/abstracts/custom-props.css +++ b/packages/cfpb-design-system/src/elements/abstracts/custom-props.css @@ -7,7 +7,7 @@ --beige-30: #f0e8d8; --beige-60: #d8c8a0; --black: #101820; - --font-stack: system-ui, sans-serif; + --font-stack: system-ui,sans-serif; --gold: #ff9e1b; --gold-10: #fff6ec; --gold-20: #fff0dd; diff --git a/packages/cfpb-design-system/src/elements/abstracts/sizing-tokens-custom-props.css b/packages/cfpb-design-system/src/elements/abstracts/sizing-tokens-custom-props.css new file mode 100644 index 0000000000..b2049d61b2 --- /dev/null +++ b/packages/cfpb-design-system/src/elements/abstracts/sizing-tokens-custom-props.css @@ -0,0 +1,9 @@ +/** + * Do not edit directly, this file was auto-generated. + */ + +:root { + --font-adjust-base: 0.517; + --font-adjust-step: 0.0054; + --select-border-width-default: 1px; +} diff --git a/packages/cfpb-design-system/src/elements/abstracts/sizing-tokens.scss b/packages/cfpb-design-system/src/elements/abstracts/sizing-tokens.scss new file mode 100644 index 0000000000..394e95abff --- /dev/null +++ b/packages/cfpb-design-system/src/elements/abstracts/sizing-tokens.scss @@ -0,0 +1,25 @@ + +// Do not edit directly, this file was auto-generated. + +$base-font-size: 16; +$base-font-size-px: 16px; +$base-line-height-px: 22px; +$btn-border-radius-size: 4px; +$btn-h-padding: 14px; +$btn-v-padding: 8px; +$cf-icon-height: 1.1875em; +$fcm-min-height: 220px; +$fcm-visual-width: 270px; +$grid-gutter-width: 30px; +$grid-total-columns: 12; +$grid-wrapper-width: 1230px; +$select-height: 35px; +$size-code: 13px; +$size-i: 34px; +$size-ii: 26px; +$size-iii: 22px; +$size-iv: 18px; +$size-v: 14px; +$size-vi: 12px; +$size-xl: 48px; +$btn-font-size: $base-font-size-px; diff --git a/packages/cfpb-design-system/src/elements/abstracts/sizing-vars.scss b/packages/cfpb-design-system/src/elements/abstracts/sizing-vars.scss index 7e0c7a099b..e4e6698a16 100644 --- a/packages/cfpb-design-system/src/elements/abstracts/sizing-vars.scss +++ b/packages/cfpb-design-system/src/elements/abstracts/sizing-vars.scss @@ -1,66 +1,21 @@ +// These imports are basename-driven outputs from tokens/abstracts/sizing.json: +// - sizing.scss from top-level "sass" +// - sizing.css from top-level "css" +@forward './sizing'; @use 'sass:math'; +@use './sizing' as *; +@use './sizing.css'; -// Seperating sizing vars out because for now we are only doing JSON color tokens -// Putting these in a partial. -$base-font-size: 16; -$base-font-size-px: 16px; -$base-line-height-px: 22px; +// Derived from tokenized sizing primitives. $base-line-height: math.div($base-line-height-px, $base-font-size-px); -$btn-font-size: $base-font-size-px; -$btn-border-radius-size: 4px; -$btn-v-padding: 8px; -$btn-h-padding: 14px; -$size-xl: 48px; // Super-size -$size-i: 34px; // h1-size -$size-ii: 26px; // h2-size -$size-iii: 22px; // h3-size -$size-iv: 18px; // h4-size -$size-v: 14px; // h5-size -$size-vi: 12px; // h6-size -$size-code: 13px; // Custom size only for Mono code blocks - -// Icons' SVG viewbox are a consistent 19px (h) x variable (w). -// The height matches the 19px rendered canvas of text set in Source Sans 3 -// sized at 16px (19/16 = 1.1875). -$cf-icon-height: 1.1875em; - -// Grid variables - -$grid-wrapper-width: 1230px; -$grid-total-columns: 12; -$grid-gutter-width: 30px; - -// .a-select -$select-height: 35px; - -// Featured Content Module sizing variables. -$fcm-visual-width: 270px; -$fcm-min-height: 220px; :root { - // Typography normalization - --font-adjust-base: 0.517; - --font-adjust-step: 0.0054; - - /* Derived font-size-adjust-values */ - --font-adjust-h1: calc( - var(--font-size-adjust-base) + (5 * var(--font-adjust-step)) - ); - --font-adjust-h2: calc( - var(--font-size-adjust-base) + (4 * var(--font-adjust-step)) - ); - --font-adjust-h3: calc( - var(--font-size-adjust-base) + (3 * var(--font-adjust-step)) - ); - --font-adjust-h4: calc( - var(--font-size-adjust-base) + (2 * var(--font-adjust-step)) - ); - --font-adjust-h5: calc( - var(--font-size-adjust-base) + (1 * var(--font-adjust-step)) - ); + /* Derived font-size-adjust values */ + --font-adjust-h1: calc(var(--font-adjust-base) + (5 * var(--font-adjust-step))); + --font-adjust-h2: calc(var(--font-adjust-base) + (4 * var(--font-adjust-step))); + --font-adjust-h3: calc(var(--font-adjust-base) + (3 * var(--font-adjust-step))); + --font-adjust-h4: calc(var(--font-adjust-base) + (2 * var(--font-adjust-step))); + --font-adjust-h5: calc(var(--font-adjust-base) + (1 * var(--font-adjust-step))); --font-adjust-h6: var(--font-adjust-base); --font-adjust-body: var(--font-adjust-base); - - // .a-select - --select-border-width-default: 1px; } diff --git a/packages/cfpb-design-system/src/elements/abstracts/sizing.css b/packages/cfpb-design-system/src/elements/abstracts/sizing.css new file mode 100644 index 0000000000..6a6e219f93 --- /dev/null +++ b/packages/cfpb-design-system/src/elements/abstracts/sizing.css @@ -0,0 +1,10 @@ +/** + * Do not edit directly, this file was auto-generated. + */ + +:root { + --font-adjust-base: 0.517; + --font-adjust-step: 0.0054; + --select-border-width-default: 1px; + --select-border-width-error: 2px; +} diff --git a/packages/cfpb-design-system/src/elements/abstracts/sizing.scss b/packages/cfpb-design-system/src/elements/abstracts/sizing.scss new file mode 100644 index 0000000000..394e95abff --- /dev/null +++ b/packages/cfpb-design-system/src/elements/abstracts/sizing.scss @@ -0,0 +1,25 @@ + +// Do not edit directly, this file was auto-generated. + +$base-font-size: 16; +$base-font-size-px: 16px; +$base-line-height-px: 22px; +$btn-border-radius-size: 4px; +$btn-h-padding: 14px; +$btn-v-padding: 8px; +$cf-icon-height: 1.1875em; +$fcm-min-height: 220px; +$fcm-visual-width: 270px; +$grid-gutter-width: 30px; +$grid-total-columns: 12; +$grid-wrapper-width: 1230px; +$select-height: 35px; +$size-code: 13px; +$size-i: 34px; +$size-ii: 26px; +$size-iii: 22px; +$size-iv: 18px; +$size-v: 14px; +$size-vi: 12px; +$size-xl: 48px; +$btn-font-size: $base-font-size-px; diff --git a/packages/cfpb-design-system/src/elements/abstracts/vars.css b/packages/cfpb-design-system/src/elements/abstracts/vars.css index 35fc7db0fb..62a9067c74 100644 --- a/packages/cfpb-design-system/src/elements/abstracts/vars.css +++ b/packages/cfpb-design-system/src/elements/abstracts/vars.css @@ -64,12 +64,7 @@ --select-text-disabled-default: var(--gray-dark); --table-border: var(--gray); --table-head-bg: var(--gray-5); - --tag-filter-bg-active-default: var(--teal-60); - --tag-filter-bg-default: var(--teal-20); - --tag-filter-bg-hover-default: var(--teal-40); - --tag-filter-border-default: var(--teal); - --tag-filter-outline-focuse-default: var(--teal-dark); - --text: var(--black); /** body */ + --text: var(--black); --block-border-bottom: var(--block-border); --block-border-left: var(--block-border); --block-border-right: var(--block-border); diff --git a/packages/cfpb-design-system/src/tokens/abstracts/sizing.json b/packages/cfpb-design-system/src/tokens/abstracts/sizing.json new file mode 100644 index 0000000000..537a5a0d4d --- /dev/null +++ b/packages/cfpb-design-system/src/tokens/abstracts/sizing.json @@ -0,0 +1,305 @@ +{ + "css": { + "dimension-px": { + "select-border-width-default": { + "$type": "number", + "$value": 1, + "$extensions": { + "com.figma.variableId": "VariableID:9284:8", + "com.figma.scopes": [ + "ALL_SCOPES" + ], + "com.figma.isOverride": true + } + }, + "select-border-width-error": { + "$type": "number", + "$value": 2, + "$extensions": { + "com.figma.variableId": "VariableID:9358:2", + "com.figma.scopes": [ + "ALL_SCOPES" + ], + "com.figma.isOverride": true + } + } + }, + "unitless": { + "font-adjust-base": { + "$type": "number", + "$value": 0.5170000195503235, + "$extensions": { + "com.figma.variableId": "VariableID:9284:6", + "com.figma.scopes": [ + "ALL_SCOPES" + ], + "com.figma.isOverride": true + } + }, + "font-adjust-step": { + "$type": "number", + "$value": 0.005400000140070915, + "$extensions": { + "com.figma.variableId": "VariableID:9284:7", + "com.figma.scopes": [ + "ALL_SCOPES" + ], + "com.figma.isOverride": true + } + } + } + }, + "sass": { + "unitless": { + "grid-total-columns": { + "$type": "number", + "$value": 12, + "$extensions": { + "com.figma.variableId": "VariableID:258:9", + "com.figma.scopes": [ + "ALL_SCOPES" + ], + "com.figma.isOverride": true + } + }, + "base-font-size": { + "$type": "number", + "$value": 16, + "$extensions": { + "com.figma.variableId": "VariableID:256:7", + "com.figma.scopes": [ + "ALL_SCOPES" + ], + "com.figma.isOverride": true + } + } + }, + "dimension-em": { + "cf-icon-height": { + "$type": "number", + "$value": 1.1875, + "$extensions": { + "com.figma.variableId": "VariableID:258:16", + "com.figma.scopes": [ + "ALL_SCOPES" + ], + "com.figma.isOverride": true + } + } + }, + "dimension-px": { + "base-font-size-px": { + "$type": "number", + "$value": 16, + "$extensions": { + "com.figma.variableId": "VariableID:258:20", + "com.figma.scopes": [ + "ALL_SCOPES" + ], + "com.figma.isOverride": true + } + }, + "base-line-height-px": { + "$type": "number", + "$value": 22, + "$extensions": { + "com.figma.variableId": "VariableID:258:21", + "com.figma.scopes": [ + "ALL_SCOPES" + ], + "com.figma.isOverride": true + } + }, + "btn-border-radius-size": { + "$type": "number", + "$value": 4, + "$extensions": { + "com.figma.variableId": "VariableID:254:115", + "com.figma.scopes": [ + "ALL_SCOPES" + ], + "com.figma.isOverride": true + } + }, + "btn-h-padding": { + "$type": "number", + "$value": 14, + "$extensions": { + "com.figma.variableId": "VariableID:254:117", + "com.figma.scopes": [ + "ALL_SCOPES" + ], + "com.figma.isOverride": true + } + }, + "btn-v-padding": { + "$type": "number", + "$value": 8, + "$extensions": { + "com.figma.variableId": "VariableID:254:116", + "com.figma.scopes": [ + "ALL_SCOPES" + ], + "com.figma.isOverride": true + } + }, + "fcm-min-height": { + "$type": "number", + "$value": 220, + "$extensions": { + "com.figma.variableId": "VariableID:9284:3", + "com.figma.scopes": [ + "ALL_SCOPES" + ], + "com.figma.isOverride": true + } + }, + "fcm-visual-width": { + "$type": "number", + "$value": 270, + "$extensions": { + "com.figma.variableId": "VariableID:9284:4", + "com.figma.scopes": [ + "ALL_SCOPES" + ], + "com.figma.isOverride": true + } + }, + "grid-gutter-width": { + "$type": "number", + "$value": 30, + "$extensions": { + "com.figma.variableId": "VariableID:258:8", + "com.figma.scopes": [ + "ALL_SCOPES" + ], + "com.figma.isOverride": true + } + }, + "grid-wrapper-width": { + "$type": "number", + "$value": 1230, + "$extensions": { + "com.figma.variableId": "VariableID:258:7", + "com.figma.scopes": [ + "ALL_SCOPES" + ], + "com.figma.isOverride": true + } + }, + "select-height": { + "$type": "number", + "$value": 35, + "$extensions": { + "com.figma.variableId": "VariableID:9284:5", + "com.figma.scopes": [ + "ALL_SCOPES" + ], + "com.figma.isOverride": true + } + }, + "size-code": { + "$type": "number", + "$value": 13, + "$extensions": { + "com.figma.variableId": "VariableID:258:29", + "com.figma.scopes": [ + "ALL_SCOPES" + ], + "com.figma.isOverride": true + } + }, + "size-i": { + "$type": "number", + "$value": 34, + "$extensions": { + "com.figma.variableId": "VariableID:258:23", + "com.figma.scopes": [ + "ALL_SCOPES" + ], + "com.figma.isOverride": true + } + }, + "size-ii": { + "$type": "number", + "$value": 26, + "$extensions": { + "com.figma.variableId": "VariableID:258:24", + "com.figma.scopes": [ + "ALL_SCOPES" + ], + "com.figma.isOverride": true + } + }, + "size-iii": { + "$type": "number", + "$value": 22, + "$extensions": { + "com.figma.variableId": "VariableID:258:25", + "com.figma.scopes": [ + "ALL_SCOPES" + ], + "com.figma.isOverride": true + } + }, + "size-iv": { + "$type": "number", + "$value": 18, + "$extensions": { + "com.figma.variableId": "VariableID:258:26", + "com.figma.scopes": [ + "ALL_SCOPES" + ], + "com.figma.isOverride": true + } + }, + "size-v": { + "$type": "number", + "$value": 14, + "$extensions": { + "com.figma.variableId": "VariableID:258:27", + "com.figma.scopes": [ + "ALL_SCOPES" + ], + "com.figma.isOverride": true + } + }, + "size-vi": { + "$type": "number", + "$value": 12, + "$extensions": { + "com.figma.variableId": "VariableID:258:28", + "com.figma.scopes": [ + "ALL_SCOPES" + ], + "com.figma.isOverride": true + } + }, + "size-xl": { + "$type": "number", + "$value": 48, + "$extensions": { + "com.figma.variableId": "VariableID:258:22", + "com.figma.scopes": [ + "ALL_SCOPES" + ], + "com.figma.isOverride": true + } + }, + "btn-font-size": { + "$type": "number", + "$value": "{sass.dimension-px.base-font-size-px}", + "$extensions": { + "com.figma.variableId": "VariableID:254:114", + "com.figma.scopes": [ + "ALL_SCOPES" + ], + "com.figma.isOverride": true + } + } + } + }, + "$extensions": { + "com.figma.modeName": "default" + } +} \ No newline at end of file diff --git a/style-dictionary.config.js b/style-dictionary.config.js index 3848a52a66..c4cbf72904 100644 --- a/style-dictionary.config.js +++ b/style-dictionary.config.js @@ -1,366 +1,62 @@ -// style-dictionary.config.js — ESM -// Summary: -// - Builds one CSS file per token JSON under packages/cfpb-design-system/src/tokens. -// - Uses DTCG $type for transforms; names are full-path kebab-case. -// - Prefers hex colors, emits CSS v4 rgba() only when RGBA is present and hex is absent. -// - Preserves Figma alias metadata as var(--...) with collision checks. -// - Outputs tight comma spacing in CSS values without touching comments. +// Style Dictionary entrypoint. +// This file intentionally stays as a thin composition layer: +// - utility modules hold transform/filter/format implementation details +// - this file wires registration order and platform outputs together import StyleDictionary from 'style-dictionary'; -import fs from 'fs'; -import path from 'path'; import { logBrokenReferenceLevels, logVerbosityLevels, logWarningLevels, - propertyFormatNames, } from 'style-dictionary/enums'; -import { fileHeader, formattedVariables } from 'style-dictionary/utils'; +import path from 'path'; +import { toAbsPosix, toKebab, toPosix } from './style-dictionary/plugins/path-utils.js'; +import { buildFilesAndFilters } from './style-dictionary/plugins/file-discovery.js'; +import { + colorRgbaV4Transform, + colorWarnNormalizeTransform, + getAliasInfo, + warn, +} from './style-dictionary/plugins/color.js'; +import { + intentLeafNameTransform, + intentUnitByPathTransform, + numberRoundTransform, +} from './style-dictionary/plugins/sizing.js'; +import { createCssVariablesNoSpaceCommasFormat } from './style-dictionary/plugins/css-variables-no-space-commas.js'; +import { createPlatforms } from './style-dictionary/plugins/platforms.js'; -// Paths and shared constants for token IO and formatting. const baseDir = 'packages/cfpb-design-system/src'; const tokenBase = path.resolve(baseDir, 'tokens'); const cssFormatName = 'css/variables-no-space-commas'; -const hexPattern = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i; -const rgbaPattern = /^rgba\(/i; - -const toPosix = (fsPath) => fsPath.split(path.sep).join('/'); -const toAbsPosix = (fsPath) => - toPosix(path.isAbsolute(fsPath) ? fsPath : path.resolve(fsPath)); -const toKebab = (value) => - String(value) - .replace(/([a-z0-9])([A-Z])/g, '$1-$2') - .replace(/[\s_]+/g, '-') - .replace(/-+/g, '-') - .toLowerCase(); - -/** - * Return nested subdirectories in depth-first order. - * @param {string} dirPath - * @returns {string[]} - */ -function getAllDirs(dirPath) { - const out = []; - for (const dirent of fs.readdirSync(dirPath, { withFileTypes: true })) { - if (dirent.isDirectory() && !dirent.name.startsWith('.')) { - const full = path.join(dirPath, dirent.name); - out.push(full, ...getAllDirs(full)); - } - } - return out; -} - -// Emit warnings in a way that matches Style Dictionary log settings. -const warn = (options, message) => { - if (options.log?.warnings === logWarningLevels.error) - throw new Error(message); - if ( - options.log?.warnings !== logWarningLevels.disabled && - options.log?.verbosity !== logVerbosityLevels.silent - ) { - // eslint-disable-next-line no-console - console.warn(message); - } -}; - -// Normalize comma spacing inside single-line CSS values, leaving comments untouched. -const normalizeCommaSpacing = (value) => - value.replace(/(:[^;\n]*)(;)/g, (match, valuePart, semi) => - valuePart.includes(',') - ? `${valuePart.replace(/\s*,\s*/g, ',')}${semi}` - : match, - ); - -// Build one CSS output per token JSON and register per-file filters. -const buildFilesAndFilters = (basePath) => { - const tokenDirs = [basePath, ...getAllDirs(basePath)]; - const files = []; - const filtersToRegister = []; - - for (const dirPath of tokenDirs) { - const jsonFiles = fs - .readdirSync(dirPath) - .filter((file) => file.endsWith('.json')); - for (const jsonFile of jsonFiles) { - const fullPathAbsPosix = toAbsPosix(path.join(dirPath, jsonFile)); - const relDir = path.relative(basePath, dirPath); - const cssFileName = `${path.basename(jsonFile, '.json')}.css`; - const destination = - relDir === '' ? cssFileName : `${toPosix(relDir)}/${cssFileName}`; - const filterName = `filter__${(relDir || '_').replace( - /[^a-zA-Z0-9_/-]/g, - '_', - )}__${jsonFile.replace(/[^a-zA-Z0-9_.-]/g, '_')}`.replace( - /[^a-zA-Z0-9_]/g, - '_', - ); - filtersToRegister.push({ filterName, fullPathAbsPosix }); - files.push({ - destination, - format: cssFormatName, - filter: filterName, - options: { outputReferences: true, usesDtcg: true, selector: ':root' }, - }); - } - } - - return { files, filtersToRegister }; -}; - -// Color helpers for DTCG $value shapes and css rgba passthrough. -const isColorToken = (token) => (token?.$type ?? token?.type) === 'color'; -const getRawTokenValue = (token) => - token?.original?.$value ?? - token?.$value ?? - token?.original?.value ?? - token?.value; -const isReferenceValue = (value) => - typeof value === 'string' && - value.trim().startsWith('{') && - value.trim().endsWith('}'); -const getHexValue = (value) => { - if (typeof value === 'string') { - const trimmed = value.trim(); - return hexPattern.test(trimmed) ? trimmed : null; - } - const hex = - value && typeof value === 'object' ? value.hex || value.hexa : null; - return typeof hex === 'string' && hexPattern.test(hex.trim()) - ? hex.trim() - : null; -}; -const parseRgbaParts = (value) => { - if (!value) return null; - if (typeof value === 'string') { - const trimmed = value.trim(); - if (!rgbaPattern.test(trimmed)) return null; - const start = trimmed.indexOf('('); - const end = trimmed.lastIndexOf(')'); - if (start === -1 || end === -1 || end <= start + 1) return null; - const body = trimmed.slice(start + 1, end).trim(); - if (!body) return null; - if (body.includes('/')) { - const [rgbPart, alphaPart] = body.split('/'); - const rgbValues = rgbPart - .replace(/,/g, ' ') - .trim() - .split(/\s+/) - .filter(Boolean); - const alpha = alphaPart.trim(); - return rgbValues.length === 3 && alpha - ? { r: rgbValues[0], g: rgbValues[1], b: rgbValues[2], a: alpha } - : null; - } - const parts = body.split(/[\s,]+/).filter(Boolean); - return parts.length === 4 - ? { r: parts[0], g: parts[1], b: parts[2], a: parts[3] } - : null; - } - if (typeof value === 'object' && Array.isArray(value.components)) { - const [r, g, b] = value.components; - const a = value.alpha ?? value.opacity; - return a === undefined || a === null ? null : { r, g, b, a }; - } - return null; -}; -// Describe a token's color payload to decide hex vs rgba vs normalization. -const getColorInfo = (token) => { - const raw = getRawTokenValue(token); - const hex = getHexValue(raw); - const isRef = isReferenceValue(raw); - const rgbaParts = !hex && !isRef ? parseRgbaParts(raw) : null; - return { raw, hex, rgbaParts, isRef }; -}; - -// Convert DTCG color objects to a shape color/css can consume. -const normalizeColorInput = (value) => { - if (!value || typeof value !== 'object') return value; - if (value.colorSpace === 'srgb' && Array.isArray(value.components)) { - const hex = getHexValue(value); - if (hex) return hex; - const [r, g, b] = value.components; - const a = value.alpha ?? value.opacity; - const toChannel = (channelValue) => - channelValue <= 1 - ? Math.round(channelValue * 255) - : Math.round(channelValue); - const out = { - r: toChannel(r), - g: toChannel(g), - b: toChannel(b), - }; - if (a !== undefined && a !== null) { - out.a = a > 1 && a <= 255 ? a / 255 : a; - } - return out; - } - return value; -}; - -// Prefer Figma alias metadata so CSS keeps var(--alias) instead of resolved values. -const getAliasInfo = (token) => { - const aliasData = - token?.$extensions?.['com.figma.aliasData'] ?? - token?.extensions?.['com.figma.aliasData']; - return aliasData?.targetVariableName - ? { - name: aliasData.targetVariableName, - setName: aliasData.targetVariableSetName, - } - : null; -}; - -const { files, filtersToRegister } = buildFilesAndFilters(tokenBase); - -// Warn when we must normalize non-hex color objects (except RGBA passthrough). -const colorWarnNormalizeTransform = { - type: 'value', - filter: isColorToken, - transform: (token, _, options = {}) => { - const info = getColorInfo(token); - const value = normalizeColorInput(info.raw); - if ( - info.raw && - typeof info.raw === 'object' && - !info.hex && - !info.rgbaParts - ) { - const tokenName = - token.name || - (Array.isArray(token.path) ? token.path.join('.') : 'unknown'); - const tokenFile = token.filePath || 'unknown file'; - warn( - options, - `Color token ${tokenName} in ${tokenFile} will be normalized by color/css.`, - ); - } - return value; - }, +// Keep file-level options centralized so default and intent outputs stay aligned +// on reference behavior and deterministic ordering. +const sharedCssFileOptions = { + outputReferences: true, + usesDtcg: true, + selector: ':root', + sort: 'name', }; - -// Emit CSS Color v4 rgba() only when RGBA is present and hex is absent. -const colorRgbaV4Transform = { - type: 'value', - filter: isColorToken, - transform: (token) => { - const info = getColorInfo(token); - if (!info.rgbaParts) return token.$value ?? token.value; - const { r, g, b, a } = info.rgbaParts; - return `rgba(${r} ${g} ${b} / ${a})`; - }, +const sharedScssFileOptions = { + outputReferences: true, + usesDtcg: true, + sort: 'name', }; -// Custom CSS formatter that preserves alias refs and tightens commas. -const cssVariablesNoSpaceCommasFormat = async ({ - dictionary, - options = {}, - file, -}) => { - const selector = Array.isArray(options.selector) - ? options.selector - : options.selector - ? [options.selector] - : [':root']; - const { outputReferences, outputReferenceFallbacks, usesDtcg, formatting } = - options; - const header = await fileHeader({ - file, - formatting: formatting ? { ...formatting, prefix: undefined } : formatting, - options, +const { defaultCssFiles, intentCssFiles, intentScssFiles, filtersToRegister } = + buildFilesAndFilters({ + basePath: tokenBase, + cssFormat: cssFormatName, + cssFileOptions: sharedCssFileOptions, + scssFileOptions: sharedScssFileOptions, }); - const indentation = formatting?.indentation || ' '; - const nestInSelector = (content, currentSelector, indent) => - `${indent}${currentSelector} {\n${content}\n${indent}}`; - const aliasInfoByName = new Map(); - if (outputReferences && usesDtcg) { - for (const token of dictionary.allTokens) { - const aliasInfo = getAliasInfo(token); - if (!aliasInfo?.name) continue; - const aliasKey = toKebab(aliasInfo.name); - const record = aliasInfoByName.get(aliasKey) || { - count: 0, - setNames: new Set(), - missingSetName: false, - }; - record.count += 1; - if (aliasInfo.setName) record.setNames.add(toKebab(aliasInfo.setName)); - else record.missingSetName = true; - aliasInfoByName.set(aliasKey, record); - } - } - const warnedAliases = new Set(); - const dictionaryForAliases = - outputReferences && usesDtcg - ? { - ...dictionary, - allTokens: dictionary.allTokens.map((token) => { - const aliasInfo = getAliasInfo(token); - if (!aliasInfo) return token; - const aliasKey = toKebab(aliasInfo.name); - const record = aliasInfoByName.get(aliasKey); - const hasCollision = record && record.count > 1; - if (hasCollision && record.missingSetName) { - throw new Error( - `Alias name collision for "${aliasInfo.name}" without targetVariableSetName. ` + - `Cannot disambiguate in ${token.filePath || 'unknown file'}.`, - ); - } - if ( - hasCollision && - record.setNames.size > 1 && - !warnedAliases.has(aliasKey) - ) { - warn( - options, - `Alias name collision for "${aliasInfo.name}". ` + - 'Multiple collections share the same alias name.', - ); - warnedAliases.add(aliasKey); - } - const aliasVar = `var(--${aliasKey})`; - return { - ...token, - value: aliasVar, - $value: aliasVar, - original: { - ...token.original, - value: aliasVar, - $value: aliasVar, - }, - }; - }), - } - : dictionary; - const variablesNoCommaSpaces = normalizeCommaSpacing( - formattedVariables({ - format: propertyFormatNames.css, - dictionary: dictionaryForAliases, - outputReferences, - outputReferenceFallbacks, - formatting: { - ...formatting, - indentation: indentation.repeat(selector.length), - }, - usesDtcg, - }), - ); - return ( - header + - selector - .reverse() - .reduce( - (content, currentSelector, index) => - nestInSelector( - content, - currentSelector, - indentation.repeat(selector.length - 1 - index), - ), - variablesNoCommaSpaces, - ) + - '\n' - ); -}; +const cssVariablesNoSpaceCommasFormat = createCssVariablesNoSpaceCommasFormat({ + getAliasInfo, + toKebab, + warn, +}); +// Register custom transforms used by both default and intent outputs. StyleDictionary.registerTransform({ name: 'value/color-warn-normalize', ...colorWarnNormalizeTransform, @@ -369,10 +65,40 @@ StyleDictionary.registerTransform({ name: 'value/color-rgba-v4', ...colorRgbaV4Transform, }); +StyleDictionary.registerTransform({ + name: 'name/intent-leaf-kebab', + ...intentLeafNameTransform, +}); +StyleDictionary.registerTransform({ + name: 'value/number-round-4', + ...numberRoundTransform, +}); +StyleDictionary.registerTransform({ + name: 'value/intent-unit-by-path', + ...intentUnitByPathTransform, +}); + +// Register transform groups: +// - css/without-group: default CSS output +// - intent/without-group: sass/css top-level intent outputs with unit-by-path StyleDictionary.registerTransformGroup({ name: 'css/without-group', transforms: [ 'name/kebab', + 'value/number-round-4', + 'value/color-warn-normalize', + 'color/css', + 'value/color-rgba-v4', + 'time/seconds', + 'size/px', + ], +}); +StyleDictionary.registerTransformGroup({ + name: 'intent/without-group', + transforms: [ + 'name/intent-leaf-kebab', + 'value/number-round-4', + 'value/intent-unit-by-path', 'value/color-warn-normalize', 'color/css', 'value/color-rgba-v4', @@ -380,18 +106,44 @@ StyleDictionary.registerTransformGroup({ 'size/px', ], }); + StyleDictionary.registerFormat({ name: cssFormatName, format: cssVariablesNoSpaceCommasFormat, }); -for (const { filterName, fullPathAbsPosix } of filtersToRegister) { +// Each token file gets a file-scoped filter so generated CSS files map 1:1 +// with token JSON inputs. includeRoots/excludeRoots further split mixed files +// so intent groups (sass/css) and non-intent groups are emitted separately. +for (const { + filterName, + fullPathAbsPosix, + includeRoots, + excludeRoots, +} of filtersToRegister) { + const includeSet = includeRoots ? new Set(includeRoots) : null; + const excludeSet = excludeRoots ? new Set(excludeRoots) : null; StyleDictionary.registerFilter({ name: filterName, - filter: (token) => toAbsPosix(token.filePath) === fullPathAbsPosix, + filter: (token) => { + if (toAbsPosix(token.filePath) !== fullPathAbsPosix) return false; + const topLevel = + Array.isArray(token.path) && token.path.length ? token.path[0] : ''; + if (includeSet && !includeSet.has(topLevel)) return false; + if (excludeSet && excludeSet.has(topLevel)) return false; + return true; + }, }); } +const platforms = createPlatforms({ + baseDir, + tokenBase, + defaultCssFiles, + intentCssFiles, + intentScssFiles, +}); + export default { usesDtcg: true, source: [`${toPosix(tokenBase)}/**/*.json`], @@ -400,11 +152,5 @@ export default { verbosity: logVerbosityLevels.verbose, errors: { brokenReferences: logBrokenReferenceLevels.throw }, }, - platforms: { - css: { - transformGroup: 'css/without-group', - buildPath: `${baseDir}/elements/`, - files, - }, - }, + platforms, }; diff --git a/style-dictionary/plugins/color.js b/style-dictionary/plugins/color.js new file mode 100644 index 0000000000..73001049de --- /dev/null +++ b/style-dictionary/plugins/color.js @@ -0,0 +1,180 @@ +import { logVerbosityLevels, logWarningLevels } from 'style-dictionary/enums'; + +const hexPattern = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i; +const rgbaPattern = /^rgba\(/i; + +// Style Dictionary may represent token type as $type (DTCG) or type (legacy). +export const isColorToken = (token) => (token?.$type ?? token?.type) === 'color'; + +const getRawTokenValue = (token) => + token?.original?.$value ?? + token?.$value ?? + token?.original?.value ?? + token?.value; + +const isReferenceValue = (value) => + typeof value === 'string' && + value.trim().startsWith('{') && + value.trim().endsWith('}'); + +const getHexValue = (value) => { + if (typeof value === 'string') { + const trimmed = value.trim(); + return hexPattern.test(trimmed) ? trimmed : null; + } + + const hex = + value && typeof value === 'object' ? value.hex || value.hexa : null; + return typeof hex === 'string' && hexPattern.test(hex.trim()) + ? hex.trim() + : null; +}; + +const parseRgbaParts = (value) => { + if (!value) return null; + + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!rgbaPattern.test(trimmed)) return null; + + const start = trimmed.indexOf('('); + const end = trimmed.lastIndexOf(')'); + if (start === -1 || end === -1 || end <= start + 1) return null; + + const body = trimmed.slice(start + 1, end).trim(); + if (!body) return null; + + if (body.includes('/')) { + const [rgbPart, alphaPart] = body.split('/'); + const rgbValues = rgbPart + .replace(/,/g, ' ') + .trim() + .split(/\s+/) + .filter(Boolean); + const alpha = alphaPart.trim(); + + return rgbValues.length === 3 && alpha + ? { r: rgbValues[0], g: rgbValues[1], b: rgbValues[2], a: alpha } + : null; + } + + const parts = body.split(/[\s,]+/).filter(Boolean); + return parts.length === 4 + ? { r: parts[0], g: parts[1], b: parts[2], a: parts[3] } + : null; + } + + if (typeof value === 'object' && Array.isArray(value.components)) { + const [r, g, b] = value.components; + const a = value.alpha ?? value.opacity; + return a === undefined || a === null ? null : { r, g, b, a }; + } + + return null; +}; + +const getColorInfo = (token) => { + const raw = getRawTokenValue(token); + const hex = getHexValue(raw); + const isRef = isReferenceValue(raw); + const rgbaParts = !hex && !isRef ? parseRgbaParts(raw) : null; + return { raw, hex, rgbaParts, isRef }; +}; + +// Normalize Figma-style srgb object values into color/css-friendly channels. +const normalizeColorInput = (value) => { + if (!value || typeof value !== 'object') return value; + + if (value.colorSpace === 'srgb' && Array.isArray(value.components)) { + const hex = getHexValue(value); + if (hex) return hex; + + const [r, g, b] = value.components; + const a = value.alpha ?? value.opacity; + const toChannel = (channelValue) => + channelValue <= 1 + ? Math.round(channelValue * 255) + : Math.round(channelValue); + + const out = { + r: toChannel(r), + g: toChannel(g), + b: toChannel(b), + }; + + if (a !== undefined && a !== null) { + out.a = a > 1 && a <= 255 ? a / 255 : a; + } + + return out; + } + + return value; +}; + +// Extract Figma alias metadata used by the custom CSS format when outputReferences +// is enabled. This lets us preserve alias intent while detecting collisions. +export const getAliasInfo = (token) => { + const aliasData = + token?.$extensions?.['com.figma.aliasData'] ?? + token?.extensions?.['com.figma.aliasData']; + + return aliasData?.targetVariableName + ? { + name: aliasData.targetVariableName, + setName: aliasData.targetVariableSetName, + } + : null; +}; + +// Emit warnings in a way that matches Style Dictionary log settings. +export const warn = (options, message) => { + if (options.log?.warnings === logWarningLevels.error) throw new Error(message); + + if ( + options.log?.warnings !== logWarningLevels.disabled && + options.log?.verbosity !== logVerbosityLevels.silent + ) { + // eslint-disable-next-line no-console + console.warn(message); + } +}; + +// Preprocess color values before builtin color/css runs: +// - pass through reference strings +// - normalize srgb objects to channel values +// - emit maintainers warnings when coercion is required +export const colorWarnNormalizeTransform = { + type: 'value', + filter: isColorToken, + transform: (token, _, options = {}) => { + const info = getColorInfo(token); + const value = normalizeColorInput(info.raw); + + if (info.raw && typeof info.raw === 'object' && !info.hex && !info.rgbaParts) { + const tokenName = + token.name || + (Array.isArray(token.path) ? token.path.join('.') : 'unknown'); + const tokenFile = token.filePath || 'unknown file'; + warn( + options, + `Color token ${tokenName} in ${tokenFile} will be normalized by color/css.`, + ); + } + + return value; + }, +}; + +// Normalize rgba(...) values to CSS Color 4 "space-separated" syntax so emitted +// tokens are consistent regardless of source formatting. +export const colorRgbaV4Transform = { + type: 'value', + filter: isColorToken, + transform: (token) => { + const info = getColorInfo(token); + if (!info.rgbaParts) return token.$value ?? token.value; + const { r, g, b, a } = info.rgbaParts; + return `rgba(${r} ${g} ${b} / ${a})`; + }, +}; diff --git a/style-dictionary/plugins/css-variables-no-space-commas.js b/style-dictionary/plugins/css-variables-no-space-commas.js new file mode 100644 index 0000000000..50aea42ef4 --- /dev/null +++ b/style-dictionary/plugins/css-variables-no-space-commas.js @@ -0,0 +1,146 @@ +import { propertyFormatNames } from 'style-dictionary/enums'; +import { fileHeader, formattedVariables } from 'style-dictionary/utils'; + +// Keep comma-separated CSS value lists compact (e.g. rgba channels, font stacks). +const normalizeCommaSpacing = (value) => + value.replace(/(:[^;\n]*)(;)/g, (match, valuePart, semi) => + valuePart.includes(',') + ? `${valuePart.replace(/\s*,\s*/g, ',')}${semi}` + : match, + ); + +export const createCssVariablesNoSpaceCommasFormat = ({ + getAliasInfo, + toKebab, + warn, +}) => + async ({ dictionary, options = {}, file }) => { + // Support single selector string or nested selector arrays. + const selector = Array.isArray(options.selector) + ? options.selector + : options.selector + ? [options.selector] + : [':root']; + + const { + outputReferences, + outputReferenceFallbacks, + usesDtcg, + formatting, + sort, + } = options; + + const header = await fileHeader({ + file, + formatting: formatting ? { ...formatting, prefix: undefined } : formatting, + options, + }); + + const indentation = formatting?.indentation || ' '; + const nestInSelector = (content, currentSelector, indent) => + `${indent}${currentSelector} {\n${content}\n${indent}}`; + + const aliasInfoByName = new Map(); + if (outputReferences && usesDtcg) { + for (const token of dictionary.allTokens) { + const aliasInfo = getAliasInfo(token); + if (!aliasInfo?.name) continue; + + const aliasKey = toKebab(aliasInfo.name); + const record = aliasInfoByName.get(aliasKey) || { + count: 0, + setNames: new Set(), + missingSetName: false, + }; + + record.count += 1; + if (aliasInfo.setName) record.setNames.add(toKebab(aliasInfo.setName)); + else record.missingSetName = true; + aliasInfoByName.set(aliasKey, record); + } + } + + const warnedAliases = new Set(); + + // When outputReferences is enabled with DTCG tokens, use Figma alias data to + // resolve target CSS var names and detect ambiguous alias collisions early. + const dictionaryForAliases = + outputReferences && usesDtcg + ? { + ...dictionary, + allTokens: dictionary.allTokens.map((token) => { + const aliasInfo = getAliasInfo(token); + if (!aliasInfo) return token; + + const aliasKey = toKebab(aliasInfo.name); + const record = aliasInfoByName.get(aliasKey); + const hasCollision = record && record.count > 1; + + if (hasCollision && record.missingSetName) { + throw new Error( + `Alias name collision for "${aliasInfo.name}" without targetVariableSetName. ` + + `Cannot disambiguate in ${token.filePath || 'unknown file'}.`, + ); + } + + if ( + hasCollision && + record.setNames.size > 1 && + !warnedAliases.has(aliasKey) + ) { + warn( + options, + `Alias name collision for "${aliasInfo.name}". ` + + 'Multiple collections share the same alias name.', + ); + warnedAliases.add(aliasKey); + } + + const aliasVar = `var(--${aliasKey})`; + return { + ...token, + value: aliasVar, + $value: aliasVar, + original: { + ...token.original, + value: aliasVar, + $value: aliasVar, + }, + }; + }), + } + : dictionary; + + const variablesNoCommaSpaces = normalizeCommaSpacing( + formattedVariables({ + format: propertyFormatNames.css, + dictionary: dictionaryForAliases, + outputReferences, + outputReferenceFallbacks, + formatting: { + ...formatting, + indentation: indentation.repeat(selector.length), + }, + usesDtcg, + sort, + }), + ); + + return ( + // Wrap the variable block inside selectors from inside-out so callers can + // pass nested selectors (e.g. [':root', '.theme-a']). + header + + selector + .reverse() + .reduce( + (content, currentSelector, index) => + nestInSelector( + content, + currentSelector, + indentation.repeat(selector.length - 1 - index), + ), + variablesNoCommaSpaces, + ) + + '\n' + ); + }; diff --git a/style-dictionary/plugins/file-discovery.js b/style-dictionary/plugins/file-discovery.js new file mode 100644 index 0000000000..6f67b06d43 --- /dev/null +++ b/style-dictionary/plugins/file-discovery.js @@ -0,0 +1,182 @@ + +import fs from 'fs'; +import path from 'path'; +import { toAbsPosix, toPosix } from './path-utils.js'; + +const INTENT_ROOTS = new Set(['sass', 'css']); + +/** + * Return nested subdirectories in depth-first order. + * @param {string} dirPath + * @returns {string[]} + */ +export function getAllDirs(dirPath) { + const out = []; + for (const dirent of fs.readdirSync(dirPath, { withFileTypes: true })) { + if (dirent.isDirectory() && !dirent.name.startsWith('.')) { + const full = path.join(dirPath, dirent.name); + out.push(full, ...getAllDirs(full)); + } + } + return out; +} + +const toFilterId = (value) => + value.replace(/[^a-zA-Z0-9_.-]/g, '_').replace(/[^a-zA-Z0-9_]/g, '_'); + +const createFilterName = (relDir, jsonFile, branch) => + `filter__${toFilterId(relDir || '_')}__${toFilterId(jsonFile)}__${toFilterId(branch)}`; + +const getDestination = (relDir, basename, ext, suffix = '') => + relDir === '' + ? `${basename}${suffix}.${ext}` + : `${toPosix(relDir)}/${basename}${suffix}.${ext}`; + +const getTokenTopLevelRoots = (fullPath) => { + const json = JSON.parse(fs.readFileSync(fullPath, 'utf8')); + // $extensions is metadata and not a token branch. + return Object.keys(json).filter((key) => key !== '$extensions'); +}; + +const classifyRoots = (roots) => { + const hasSassRoot = roots.includes('sass'); + const hasCssRoot = roots.includes('css'); + const nonIntentRoots = roots.filter((root) => !INTENT_ROOTS.has(root)); + return { + hasSassRoot, + hasCssRoot, + nonIntentRoots, + hasNonIntentRoots: nonIntentRoots.length > 0, + }; +}; + +const buildFilterSpec = ({ + filterName, + fullPathAbsPosix, + includeRoots = null, + excludeRoots = null, +}) => ({ + filterName, + fullPathAbsPosix, + includeRoots, + excludeRoots, +}); + +export function buildFilesAndFilters({ + basePath, + cssFormat, + cssFileOptions, + scssFileOptions, +}) { + // Discovery classifies each token file by top-level intent: + // - sass-only: emit .scss + // - css-only: emit .css + // - dual: emit both + // - default: emit normal .css + // Mixed files (intent + non-intent roots) emit non-intent roots through the + // default pipeline, optionally using .default.css to avoid collisions. + const tokenDirs = [basePath, ...getAllDirs(basePath)]; + const defaultCssFiles = []; + const intentCssFiles = []; + const intentScssFiles = []; + const filtersToRegister = []; + + for (const dirPath of tokenDirs) { + const jsonFiles = fs + .readdirSync(dirPath) + .filter((file) => file.endsWith('.json')); + + for (const jsonFile of jsonFiles) { + const fullPath = path.join(dirPath, jsonFile); + const fullPathAbsPosix = toAbsPosix(fullPath); + const relDir = path.relative(basePath, dirPath); + const basename = path.basename(jsonFile, '.json'); + const roots = getTokenTopLevelRoots(fullPath); + const { hasSassRoot, hasCssRoot, nonIntentRoots, hasNonIntentRoots } = + classifyRoots(roots); + + if (hasCssRoot) { + const filterName = createFilterName(relDir, jsonFile, 'intent_css'); + filtersToRegister.push( + buildFilterSpec({ + filterName, + fullPathAbsPosix, + includeRoots: ['css'], + }), + ); + intentCssFiles.push({ + destination: getDestination(relDir, basename, 'css'), + format: cssFormat, + filter: filterName, + options: { ...cssFileOptions }, + }); + } + + if (hasSassRoot) { + const filterName = createFilterName(relDir, jsonFile, 'intent_sass'); + filtersToRegister.push( + buildFilterSpec({ + filterName, + fullPathAbsPosix, + includeRoots: ['sass'], + }), + ); + intentScssFiles.push({ + destination: getDestination(relDir, basename, 'scss'), + format: 'scss/variables', + filter: filterName, + options: { ...scssFileOptions }, + }); + } + + if (!hasSassRoot && !hasCssRoot) { + const filterName = createFilterName(relDir, jsonFile, 'default_all'); + filtersToRegister.push( + buildFilterSpec({ + filterName, + fullPathAbsPosix, + }), + ); + defaultCssFiles.push({ + destination: getDestination(relDir, basename, 'css'), + format: cssFormat, + filter: filterName, + options: { ...cssFileOptions }, + }); + continue; + } + + if (hasNonIntentRoots) { + const filterName = createFilterName( + relDir, + jsonFile, + 'default_non_intent', + ); + filtersToRegister.push( + buildFilterSpec({ + filterName, + fullPathAbsPosix, + includeRoots: nonIntentRoots, + }), + ); + // Mixed files with an intent css branch would otherwise overwrite + // .css. We emit non-intent branches to .default.css + // to preserve intent transform behavior and avoid destination collisions. + const suffix = hasCssRoot ? '.default' : ''; + defaultCssFiles.push({ + destination: getDestination(relDir, basename, 'css', suffix), + format: cssFormat, + filter: filterName, + options: { ...cssFileOptions }, + }); + } + } + } + + return { + defaultCssFiles, + intentCssFiles, + intentScssFiles, + filtersToRegister, + }; +} diff --git a/style-dictionary/plugins/path-utils.js b/style-dictionary/plugins/path-utils.js new file mode 100644 index 0000000000..0d2550a647 --- /dev/null +++ b/style-dictionary/plugins/path-utils.js @@ -0,0 +1,28 @@ +import path from 'path'; + +const numberRoundingPrecision = 4; + +// Normalize any file path to forward slashes for stable cross-platform matching. +export const toPosix = (fsPath) => fsPath.split(path.sep).join('/'); + +// Normalize relative/absolute file paths into absolute POSIX form. +export const toAbsPosix = (fsPath) => + toPosix(path.isAbsolute(fsPath) ? fsPath : path.resolve(fsPath)); + +// Convert token names/aliases into CSS-friendly kebab-case names. +export const toKebab = (value) => + String(value) + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .replace(/[\s_]+/g, '-') + .replace(/-+/g, '-') + .toLowerCase(); + +// Round numbers while stripping insignificant trailing zeros. +// Example: 0.5170000195503235 -> 0.517 (with default precision=4) +export const roundNumber = (value, precision = numberRoundingPrecision) => { + const factor = 10 ** precision; + const rounded = Math.round((value + Number.EPSILON) * factor) / factor; + return Number.isInteger(rounded) + ? rounded + : Number(rounded.toFixed(precision).replace(/\.?0+$/, '')); +}; diff --git a/style-dictionary/plugins/platforms.js b/style-dictionary/plugins/platforms.js new file mode 100644 index 0000000000..99b5915a8c --- /dev/null +++ b/style-dictionary/plugins/platforms.js @@ -0,0 +1,45 @@ +import { toPosix } from './path-utils.js'; + +export const createPlatforms = ({ + baseDir, + tokenBase, + defaultCssFiles, + intentCssFiles, + intentScssFiles, +}) => { + const allTokenSources = [`${toPosix(tokenBase)}/**/*.json`]; + const platforms = {}; + + // Default CSS platform handles files without intent roots, and non-intent + // branches from mixed files. + if (defaultCssFiles.length > 0) { + platforms.css = { + source: allTokenSources, + transformGroup: 'css/without-group', + buildPath: `${baseDir}/elements/`, + files: defaultCssFiles, + }; + } + + // Intent platforms are separated so they can use intent-specific transforms + // (leaf naming + path-based unit mapping) without affecting default tokens. + if (intentScssFiles.length > 0) { + platforms.scssIntent = { + source: allTokenSources, + transformGroup: 'intent/without-group', + buildPath: `${baseDir}/elements/`, + files: intentScssFiles, + }; + } + + if (intentCssFiles.length > 0) { + platforms.cssIntent = { + source: allTokenSources, + transformGroup: 'intent/without-group', + buildPath: `${baseDir}/elements/`, + files: intentCssFiles, + }; + } + + return platforms; +}; diff --git a/style-dictionary/plugins/sizing.js b/style-dictionary/plugins/sizing.js new file mode 100644 index 0000000000..8f2b098112 --- /dev/null +++ b/style-dictionary/plugins/sizing.js @@ -0,0 +1,61 @@ +import { roundNumber, toKebab } from './path-utils.js'; + +const INTENT_ROOTS = new Set(['sass', 'css']); + +const getTopLevelRoot = (token) => + Array.isArray(token?.path) && token.path.length ? token.path[0] : ''; + +const getUnitBucket = (token) => + Array.isArray(token?.path) && token.path.length > 1 ? token.path[1] : ''; + +const isNumberToken = (token) => (token?.$type ?? token?.type) === 'number'; + +const isIntentToken = (token) => INTENT_ROOTS.has(getTopLevelRoot(token)); + +// Apply rounding globally to number tokens so Figma float artifacts are +// normalized regardless of whether a file uses intent groups. +export const numberRoundTransform = { + type: 'value', + filter: isNumberToken, + transform: (token) => { + const value = token.$value ?? token.value; + if (typeof value === 'string') return value; + if (typeof value !== 'number') return value; + return roundNumber(value); + }, +}; + +// Intent outputs use leaf names so `sass.dimension-px.base-font-size-px` +// and `css.dimension-px.base-font-size-px` both emit `base-font-size-px`. +export const intentLeafNameTransform = { + type: 'name', + filter: isIntentToken, + transform: (token) => + toKebab( + Array.isArray(token.path) && token.path.length + ? token.path[token.path.length - 1] + : token.name, + ), +}; + +// Figma exports numbers without units. Intent files opt into unit semantics via +// the second path segment: +// - *.dimension-px.* -> px +// - *.dimension-rem.* -> rem +// - *.dimension-em.* -> em +// - *.unitless.* -> unchanged +export const intentUnitByPathTransform = { + type: 'value', + filter: (token) => isIntentToken(token) && isNumberToken(token), + transform: (token) => { + const value = token.$value ?? token.value; + if (typeof value === 'string') return value; + if (typeof value !== 'number') return value; + + const unitBucket = getUnitBucket(token); + if (unitBucket === 'dimension-px') return `${value}px`; + if (unitBucket === 'dimension-rem') return `${value}rem`; + if (unitBucket === 'dimension-em') return `${value}em`; + return value; + }, +};