Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

152 changes: 91 additions & 61 deletions projects/themes/build/css-var-completions.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import fs from 'fs';
import path from 'path';
import fs from 'node:fs';
import path from 'node:path';
import process from 'process';
import { fileURLToPath } from 'node:url';

const buildPath = 'dist/';
const sourcePath = 'src/';
Expand All @@ -11,7 +12,7 @@ function resolve(relativePath) {

// ---

function readJSONFile(jsonFilePath) {
export function readJSONFile(jsonFilePath) {
try {
const fileContents = fs.readFileSync(jsonFilePath, 'utf-8');
return JSON.parse(fileContents);
Expand All @@ -20,7 +21,7 @@ function readJSONFile(jsonFilePath) {
}
}

function writeJSONFile(jsonFilePath, data) {
export function writeJSONFile(jsonFilePath, data) {
try {
fs.writeFileSync(jsonFilePath, JSON.stringify(data, null, 2));
} catch (error) {
Expand All @@ -30,13 +31,13 @@ function writeJSONFile(jsonFilePath, data) {

// ---

function isObject(value) {
export function isObject(value) {
return typeof value === 'object' && value !== null;
}

// ---

function visitTokenTree(tokens, visitor, prefix = '') {
export function visitTokenTree(tokens, visitor, prefix = '') {
for (const [key, value] of Object.entries(tokens)) {
const path = key !== '@' ? `${prefix}${key}` : prefix.slice(0, -1);
if (isObject(value)) {
Expand All @@ -49,7 +50,7 @@ function visitTokenTree(tokens, visitor, prefix = '') {
}
}

function loadTokenDictionary(tokenJsonFilePath) {
export function loadTokenDictionary(tokenJsonFilePath) {
const tokensByPath = {};
const tokensJson = readJSONFile(resolve(tokenJsonFilePath));
visitTokenTree(tokensJson, (path, token) => {
Expand All @@ -64,31 +65,25 @@ function loadTokenDictionary(tokenJsonFilePath) {
const REFERENCE_PATTERN = /\{([^}]*)\}/g;

// Resolve all of the token value's path references (including resolution of their resolved values' path references).
function resolveTokenValue(value, tokenDictionary) {
while (value.match(REFERENCE_PATTERN)) {
value = value.replaceAll(REFERENCE_PATTERN, (_, referencedPath) => {
const referencedValue = tokenDictionary[referencedPath]?.value;
if (referencedValue === undefined) {
throw new Error(`Unable to resolve a referenced token for path: "${referencedPath}"`);
}
return referencedValue;
});
}
return value;
}
export function resolveTokenValue(value, tokenDictionary, referencePath = []) {
return value.replaceAll(REFERENCE_PATTERN, (_, referencedPath) => {
if (referencePath.includes(referencedPath)) {
throw new Error(`Cyclic token reference: ${[...referencePath, referencedPath].join(' -> ')}`);
}

// ---
const referencedValue = tokenDictionary[referencedPath]?.value;
if (referencedValue === undefined) {
throw new Error(`Unable to resolve a referenced token for path: "${referencedPath}"`);
}

const baseTokenDictionary = loadTokenDictionary(`${sourcePath}/index.json`);
const compactThemeTokenDictionary = loadTokenDictionary(`${sourcePath}/compact.json`);
const darkThemeTokenDictionary = loadTokenDictionary(`${sourcePath}/dark.json`);
const highContrastThemeTokenDictionary = loadTokenDictionary(`${sourcePath}/high-contrast.json`);
const reducedMotionThemeTokenDictionary = loadTokenDictionary(`${sourcePath}/reduced-motion.json`);
return resolveTokenValue(referencedValue, tokenDictionary, [...referencePath, referencedPath]);
});
}

const categorizedTokens = {};
// ---

// Collect a categorized token value for the specified category and path.
function collectCategorizedToken(path, details, category, value) {
function collectCategorizedToken(categorizedTokens, path, details, category, value) {
if (categorizedTokens[path] === undefined) {
categorizedTokens[path] = { ...details, values: { [category]: value } };
} else {
Expand All @@ -97,22 +92,16 @@ function collectCategorizedToken(path, details, category, value) {
}

// Collect categorized token values relative to the specified token dictionaries (last definition wins).
function collectCategorizedTokens(category, ...tokenDictionaries) {
function collectCategorizedTokens(categorizedTokens, category, ...tokenDictionaries) {
const mergedTokenDictionary = Object.assign({}, ...tokenDictionaries);
for (const [path, token] of Object.entries(mergedTokenDictionary)) {
const { value, ...details } = token;
const resolvedValue = resolveTokenValue(value, mergedTokenDictionary);
collectCategorizedToken(path, details, category, resolvedValue);
const resolvedValue = resolveTokenValue(value, mergedTokenDictionary, [path]);
collectCategorizedToken(categorizedTokens, path, details, category, resolvedValue);
}
}

collectCategorizedTokens('light', baseTokenDictionary);
collectCategorizedTokens('dark', baseTokenDictionary, darkThemeTokenDictionary);
collectCategorizedTokens('high-contrast', baseTokenDictionary, highContrastThemeTokenDictionary);
collectCategorizedTokens('compact', baseTokenDictionary, compactThemeTokenDictionary);
collectCategorizedTokens('reduced-motion', baseTokenDictionary, reducedMotionThemeTokenDictionary);

function valuesMatch(values) {
export function valuesMatch(values) {
if (values.length === 0) {
return true;
}
Expand All @@ -125,37 +114,78 @@ function valuesMatch(values) {
return true;
}

function categoryValuesMatch(values, categories) {
export function categoryValuesMatch(values, categories) {
return valuesMatch(categories.map(category => values[category]));
}

// Consolidate categorized token values that are the same.
for (const token of Object.values(categorizedTokens)) {
const values = token.values;
if (categoryValuesMatch(values, ['light', 'dark'])) {
values[''] = values['light'];
delete values['light'];
delete values['dark'];
}
if (categoryValuesMatch(values, ['', 'high-contrast']) || categoryValuesMatch(values, ['light', 'high-contrast'])) {
delete values['high-contrast'];
}
if (categoryValuesMatch(values, ['', 'compact']) || categoryValuesMatch(values, ['light', 'compact'])) {
delete values['compact'];
export function createCssVarCompletions({
baseTokenDictionary,
compactThemeTokenDictionary,
darkThemeTokenDictionary,
highContrastThemeTokenDictionary,
reducedMotionThemeTokenDictionary
}) {
const categorizedTokens = {};

collectCategorizedTokens(categorizedTokens, 'light', baseTokenDictionary);
collectCategorizedTokens(categorizedTokens, 'dark', baseTokenDictionary, darkThemeTokenDictionary);
collectCategorizedTokens(categorizedTokens, 'high-contrast', baseTokenDictionary, highContrastThemeTokenDictionary);
collectCategorizedTokens(categorizedTokens, 'compact', baseTokenDictionary, compactThemeTokenDictionary);
collectCategorizedTokens(categorizedTokens, 'reduced-motion', baseTokenDictionary, reducedMotionThemeTokenDictionary);

// Consolidate categorized token values that are the same.
for (const token of Object.values(categorizedTokens)) {
const values = token.values;
if (categoryValuesMatch(values, ['light', 'dark'])) {
values[''] = values['light'];
delete values['light'];
delete values['dark'];
}
if (categoryValuesMatch(values, ['', 'high-contrast']) || categoryValuesMatch(values, ['light', 'high-contrast'])) {
delete values['high-contrast'];
}
if (categoryValuesMatch(values, ['', 'compact']) || categoryValuesMatch(values, ['light', 'compact'])) {
delete values['compact'];
}
if (
categoryValuesMatch(values, ['', 'reduced-motion']) ||
categoryValuesMatch(values, ['light', 'reduced-motion'])
) {
delete values['reduced-motion'];
}
}
if (categoryValuesMatch(values, ['', 'reduced-motion']) || categoryValuesMatch(values, ['light', 'reduced-motion'])) {
delete values['reduced-motion'];

// Collect all tokens with their paths transformed to css variable identifiers.
const cssVarCompletions = {};
for (const [path, token] of Object.entries(categorizedTokens)) {
cssVarCompletions[`--nve-${path.replaceAll('.', '-')}`] = token;
}
}

// Collect all tokens with their paths transformed to css variable identifiers.
const cssVarCompletions = {};
for (const [path, token] of Object.entries(categorizedTokens)) {
cssVarCompletions[`--nve-${path.replaceAll('.', '-')}`] = token;
return cssVarCompletions;
}

if (!fs.existsSync(`${buildPath}`)) {
fs.mkdirSync(`${buildPath}`);
export function buildCssVarCompletions() {
const baseTokenDictionary = loadTokenDictionary(`${sourcePath}/index.json`);
const compactThemeTokenDictionary = loadTokenDictionary(`${sourcePath}/compact.json`);
const darkThemeTokenDictionary = loadTokenDictionary(`${sourcePath}/dark.json`);
const highContrastThemeTokenDictionary = loadTokenDictionary(`${sourcePath}/high-contrast.json`);
const reducedMotionThemeTokenDictionary = loadTokenDictionary(`${sourcePath}/reduced-motion.json`);

const cssVarCompletions = createCssVarCompletions({
baseTokenDictionary,
compactThemeTokenDictionary,
darkThemeTokenDictionary,
highContrastThemeTokenDictionary,
reducedMotionThemeTokenDictionary
});

if (!fs.existsSync(`${buildPath}`)) {
fs.mkdirSync(`${buildPath}`);
}

writeJSONFile('./dist/data.css-vars.json', cssVarCompletions);
}
Comment on lines +167 to 187

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Inconsistent path construction with sourcePath.

sourcePath is defined as 'src/' (line 7) with a trailing slash. Using template literals like `${sourcePath}/index.json` produces 'src//index.json'. While Node.js tolerates double slashes, this is inconsistent. Consider removing the trailing slash from sourcePath and buildPath, or use path.join() for all path construction.

Suggested path consistency fix
-const buildPath = 'dist/';
-const sourcePath = 'src/';
+const buildPath = 'dist';
+const sourcePath = 'src';

Then update usages accordingly, or use path.join(sourcePath, 'index.json') etc.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@projects/themes/build/css-var-completions.js` around lines 167 - 187, The
code constructs file paths using template literals with sourcePath and buildPath
(used in buildCssVarCompletions, loadTokenDictionary calls and
fs.mkdirSync/writeJSONFile) while sourcePath is defined with a trailing slash,
producing double slashes; change all path constructions to use path.join (e.g.,
replace `${sourcePath}/index.json` etc.) or normalize by removing trailing
slashes from sourcePath and buildPath and updating calls to loadTokenDictionary,
fs.existsSync/fs.mkdirSync, and writeJSONFile to use the normalized paths so you
never emit '//'. Ensure you import/require path if switching to path.join.


writeJSONFile('./dist/data.css-vars.json', cssVarCompletions);
if (process.argv[1] === fileURLToPath(import.meta.url)) {
buildCssVarCompletions();
}
54 changes: 54 additions & 0 deletions projects/themes/build/css-var-completions.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it } from 'vitest';
import { createCssVarCompletions, resolveTokenValue } from './css-var-completions.js';

describe('css variable completions', () => {
it('should resolve references and collapse matching theme values', () => {
const completions = createCssVarCompletions({
baseTokenDictionary: {
'ref.color.base': { value: 'white', type: 'color' },
'ref.scale.space': { value: '1' },
'ref.space.sm': { value: '{ref.scale.space} * 8px', type: 'spacing' },
'sys.layer.canvas.background': { value: '{ref.color.base}', type: 'color' }
},
darkThemeTokenDictionary: {
'ref.color.base': { value: 'black', type: 'color' }
},
highContrastThemeTokenDictionary: {
'ref.color.base': { value: 'white', type: 'color' }
},
compactThemeTokenDictionary: {
'ref.scale.space': { value: '0.8' }
},
reducedMotionThemeTokenDictionary: {}
});

expect(completions['--nve-sys-layer-canvas-background'].values).toEqual({
light: 'white',
dark: 'black'
});
expect(completions['--nve-ref-space-sm'].values).toEqual({
'': '1 * 8px',
compact: '0.8 * 8px'
});
});

it('should fail when a token reference cannot be resolved', () => {
expect(() => resolveTokenValue('{ref.color.missing}', {}, ['sys.color.text'])).toThrow(
'Unable to resolve a referenced token for path: "ref.color.missing"'
);
});

it('should fail when token references are cyclic', () => {
const tokenDictionary = {
'ref.color.a': { value: '{ref.color.b}' },
'ref.color.b': { value: '{ref.color.a}' }
};

expect(() => resolveTokenValue('{ref.color.a}', tokenDictionary, ['sys.color.text'])).toThrow(
'Cyclic token reference: sys.color.text -> ref.color.a -> ref.color.b -> ref.color.a'
);
});
});
70 changes: 38 additions & 32 deletions projects/themes/build/style-dictionary.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import StyleDictionary from 'style-dictionary';
import { formattedVariables } from 'style-dictionary/utils';
import { globSync } from 'glob';
import process from 'node:process';
import { fileURLToPath } from 'node:url';

const buildPath = 'dist/';
const sourcePath = 'src/';
Expand Down Expand Up @@ -68,46 +70,48 @@ StyleDictionary.registerTransform({
name: 'custom/validate',
type: 'value',
transitive: true,
transform: obj => {
const { value, type, name, original, filePath } = obj;
const isHighContrast = filePath.includes('high-contrast');
const isReferenceToken = name.includes('nve-ref');
const isVisualizationToken = name?.includes('nve-sys-visualization');
const isColorToken = type === 'color';
const isRawValue = !original.value.startsWith('{');
const isPxValue = original.value.endsWith('px');
const isSizeToken = name?.includes('nve-ref-size');
const isSpaceToken = name?.includes('nve-ref-space');
const isBorderToken = name?.includes('nve-ref-border');
const isOutlineToken = name?.includes('nve-ref-outline');

if (isColorToken && isRawValue && !isReferenceToken && !isVisualizationToken && !isHighContrast) {
console.error(
'\x1b[31m',
`Token ${name} is a invalid color. Color must implement a reference to a {ref.*} token to prevent cross theme color divergence`
);
throw new Error();
}
transform: validateTokenValue
});

if (isPxValue && isRawValue && !isSizeToken && !isSpaceToken && !isBorderToken && !isOutlineToken) {
console.error(
'\x1b[31m',
`Token ${name} is a invalid size/space value. Value must implement a reference to a {ref.space-*} or {ref.size-*} token to prevent cross theme layout divergence`
);
throw new Error();
}
export function validateTokenValue(obj) {
const { value, type, name, original, filePath } = obj;
const isHighContrast = filePath.includes('high-contrast');
const isReferenceToken = name.includes('nve-ref');
const isVisualizationToken = name?.includes('nve-sys-visualization');
const isColorToken = type === 'color';
const isRawValue = !original.value.startsWith('{');
const isPxValue = original.value.endsWith('px');
const isSizeToken = name?.includes('nve-ref-size');
const isSpaceToken = name?.includes('nve-ref-space');
const isBorderToken = name?.includes('nve-ref-border');
const isOutlineToken = name?.includes('nve-ref-outline');

if (isColorToken && isRawValue && !isReferenceToken && !isVisualizationToken && !isHighContrast) {
throw new Error(
`Token ${name} is an invalid color. Color must use a {ref.*} token reference to prevent cross-theme color divergence`
);
}

return value;
if (isPxValue && isRawValue && !isSizeToken && !isSpaceToken && !isBorderToken && !isOutlineToken) {
throw new Error(
`Token ${name} is an invalid size or space value. Value must use a {ref.space-*} or {ref.size-*} token reference to prevent cross-theme layout divergence`
);
}
});

return value;
}

export function getThemeSelector(theme) {
return theme !== 'index' ? `[nve-theme~='${theme}']` : `:root, [nve-theme~='light']`;
}

StyleDictionary.registerFormat({
name: 'custom/css',
format: async ({ dictionary, options }) => {
const experimental = dictionary.allTokens.find(t => t.name.includes('experimental'))
? '/*!\n * @experimental\n */'
: '';
const selector = options.theme !== 'index' ? `[nve-theme*='${options.theme}']` : `:root, [nve-theme~='light']`;
const selector = getThemeSelector(options.theme);
const config = dictionary.allTokens.filter(t => t.name.includes('config') && !t.name.includes('experimental'));
const configString = `:root{${config.map(t => `--${t.name}: ${t.value}`).join(';\n')}}`;
const formatted = formattedVariables({ format: 'css', dictionary, outputReferences: options.outputReferences })
Expand Down Expand Up @@ -195,7 +199,7 @@ StyleDictionary.registerFormat({
}
});

async function buildTokens() {
export async function buildTokens() {
const themes = globSync(`${sourcePath}*.json`).filter(path => !path.includes('index'));

const sd = new StyleDictionary({
Expand Down Expand Up @@ -328,4 +332,6 @@ function getTheme(path) {
return path.replace('dist/', '').replace(`src/`, '').split('.')[0];
}

buildTokens();
if (process.argv[1] === fileURLToPath(import.meta.url)) {
await buildTokens();
}
Loading