Skip to content
Closed
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
12 changes: 10 additions & 2 deletions packages/gamut-styles/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,16 @@
},
"repository": "git@github.com:Codecademy/skillsoft-gamut.git",
"scripts": {
"build": "nx build gamut-styles"
"build": "nx build gamut-styles",
"build:tokens": "jiti scripts/build-tokens.ts",
"validate:tokens": "jiti scripts/validate-tokens.ts"
},
"sideEffects": false,
"types": "dist/index.d.ts"
"types": "dist/index.d.ts",
"devDependencies": {
"@types/culori": "^4",
"culori": "^4.0.2",
"jiti": "2.4.2",
"style-dictionary": "5.7.0"
}
}
174 changes: 174 additions & 0 deletions packages/gamut-styles/scripts/build-tokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/* eslint-disable no-console -- CLI script; the console is its output. */
/*
* Brand → DTCG token files → tokens.css (via Style Dictionary) + TS constants.
*
* yarn workspace @skillsoft/gamut-styles build:tokens
*
* Output lands in src/tokens/generated/ and is checked in, so the theme and
* the package build don't need the generator (or culori) at runtime.
*/
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';

import StyleDictionary from 'style-dictionary';
import { fileHeader, formattedVariables } from 'style-dictionary/utils';

import { skillsoftBrand } from '../src/colorSystem/brands/skillsoft';
import {
EXTENSION,
toDtcgMode,
toDtcgPalette,
toTsModule,
} from '../src/colorSystem/emit';
import { validateMigration } from '../src/colorSystem/migration';
import type { Mode } from '../src/colorSystem/roles';
import { createColorSystem } from '../src/colorSystem/system';
import { validate } from '../src/colorSystem/validate';

const OUT = join(__dirname, '../src/tokens/generated');

const SELECTORS: Record<Mode, string> = {
light: ':root, [data-color-mode="light"]',
dark: '[data-color-mode="dark"]',
};

/*
* css/variables, plus a `--gradient-<role>` line for any role carrying a
* gradient in its $extensions. DTCG's gradient type can't describe the
* brand's stacked circles, so the CSS is precomputed by the emitter.
*
* Mode blocks also get:
* - `color-scheme`, so native controls and scrollbars follow the mode;
* - `color: var(--color-text-default)`, because `color` inherits as a computed
* value — without it, text under a nested `[data-color-mode]` keeps the
* parent mode's color (ColorMode sets it on its wrapper for the same reason);
* - a default `--color-background-current`, which ColorMode sets at runtime
* and a CSS-only subtree needs from somewhere.
*/
StyleDictionary.registerFormat({
name: 'css/gamut-variables',
format: async ({ dictionary, options, file }) => {
const header = await fileHeader({ file });
const vars = formattedVariables({
format: 'css',
dictionary,
outputReferences: options.outputReferences,
usesDtcg: true,
});
const gradients = dictionary.allTokens
.filter((t) => t.$extensions?.[EXTENSION]?.gradient)
.map(
(t) =>
` --gradient-${t.path.join('-')}: ${
t.$extensions[EXTENSION].gradient.css
};`
);
const { mode } = options as { mode?: Mode };
const modeLines = mode
? [
` color-scheme: ${mode};`,
' color: var(--color-text-default);',
' --color-background-current: var(--color-surface-default);',
]
: [];
return `${header}${options.selector} {\n${[
...modeLines,
vars,
...gradients,
].join('\n')}\n}\n`;
},
});

const cssFor = async (
sources: string[],
selector: string,
only?: string,
mode?: Mode
): Promise<string> => {
const sd = new StyleDictionary({
source: sources,
usesDtcg: true,
log: { verbosity: 'silent', warnings: 'disabled' },
platforms: {
css: {
transforms: ['name/kebab', 'color/oklch'],
prefix: 'color',
files: [
{
destination: 'unused.css',
format: 'css/gamut-variables',
filter: only ? (t) => t.filePath.endsWith(only) : undefined,
options: {
selector,
mode,
outputReferences: true,
showFileHeader: false,
},
},
],
},
},
});
const [{ output }] = await sd.formatPlatform('css');
return output as string;
};

const main = async () => {
const system = createColorSystem(skillsoftBrand);

const failed = [...validate(system), ...validateMigration(system)].filter(
(c) => !c.pass
);
if (failed.length) {
throw new Error(
`refusing to emit; failed checks:\n${failed
.map((c) => ` ${c.name}: ${c.worst} at ${c.at}`)
.join('\n')}`
);
}

await mkdir(OUT, { recursive: true });
const files = {
palette: join(OUT, 'palette.tokens.json'),
light: join(OUT, 'light.tokens.json'),
dark: join(OUT, 'dark.tokens.json'),
};
const json = (v: unknown) => `${JSON.stringify(v, null, 2)}\n`;
await writeFile(files.palette, json(toDtcgPalette(system)));
await writeFile(files.light, json(toDtcgMode(system, 'light')));
await writeFile(files.dark, json(toDtcgMode(system, 'dark')));

const css = [
`/* Generated by scripts/build-tokens.ts from the "${system.brand.name}" brand. Do not edit. */`,
await cssFor([files.palette], ':root'),
await cssFor(
[files.palette, files.light],
SELECTORS.light,
'light.tokens.json',
'light'
),
await cssFor(
[files.palette, files.dark],
SELECTORS.dark,
'dark.tokens.json',
'dark'
),
].join('\n');
await writeFile(join(OUT, 'tokens.css'), css);
/* The same CSS as a string, so GamutProvider can inject it through Emotion. */
await writeFile(
join(OUT, 'tokensCss.ts'),
`/* Generated by scripts/build-tokens.ts: tokens.css as a string. Do not edit. */\nexport const tokensCss = ${JSON.stringify(
css
)};\n`
);

await writeFile(join(OUT, 'values.ts'), toTsModule(system));

console.log(`wrote ${OUT}`);
};

main().catch((e) => {
console.error(e);
process.exit(1);
});
Loading