// Generates tonalmine/stylesheets/palettes.css: one M3 color scheme per seed color, // in light and dark. Run: cd tools/palettes && npm install && npm run build // (bundled with esbuild because material-color-utilities 0.4.0 has extensionless ESM imports). import { writeFileSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { Hct, SchemeTonalSpot, MaterialDynamicColors as C, hexFromArgb, argbFromHex, customColor } from '@material/material-color-utilities'; // The first palette is the default. Ids must match PALETTES in tonalmine/javascripts/theme.js. const PALETTES = [ ['purple', '#6750A4'], ['blue', '#0061A4'], ['teal', '#006A6A'], ['green', '#386A20'], ['orange', '#8B5000'], ]; const CUSTOM = [['success', '#386A20'], ['warning', '#7C5800']]; const ROLES = ['primary', 'onPrimary', 'primaryContainer', 'onPrimaryContainer', 'inversePrimary', 'secondary', 'onSecondary', 'secondaryContainer', 'onSecondaryContainer', 'tertiary', 'onTertiary', 'tertiaryContainer', 'onTertiaryContainer', 'error', 'onError', 'errorContainer', 'onErrorContainer', 'background', 'onBackground', 'surface', 'surfaceDim', 'surfaceBright', 'surfaceContainerLowest', 'surfaceContainerLow', 'surfaceContainer', 'surfaceContainerHigh', 'surfaceContainerHighest', 'onSurface', 'surfaceVariant', 'onSurfaceVariant', 'inverseSurface', 'inverseOnSurface', 'outline', 'outlineVariant', 'shadow', 'scrim', 'surfaceTint']; const kebab = (s) => s.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()); const hex = (argb) => hexFromArgb(argb); function roles(seedHex, dark) { const seed = argbFromHex(seedHex); const scheme = new SchemeTonalSpot(Hct.fromInt(seed), dark, 0); const lines = ROLES.map((r) => `--md-sys-color-${kebab(r)}: ${hex(C[r].getArgb(scheme))};`); for (const [name, value] of CUSTOM) { const c = customColor(seed, { name, value: argbFromHex(value), blend: true })[dark ? 'dark' : 'light']; lines.push(`--md-sys-color-${name}: ${hex(c.color)};`, `--md-sys-color-on-${name}: ${hex(c.onColor)};`, `--md-sys-color-${name}-container: ${hex(c.colorContainer)};`, `--md-sys-color-on-${name}-container: ${hex(c.onColorContainer)};`); } return lines.join(' '); } // Specificity: the default palette uses bare :root, the others add [data-palette], and every dark // rule adds one more attribute, so "palette + mode" always beats "palette" and the default. let css = `/* * Generated by tools/palettes/build_palettes.mjs; do not edit by hand. * M3 color roles (TonalSpot, contrast 0) per palette, light and dark. * picks the palette, forces a mode; * without data-mode the OS setting (prefers-color-scheme) decides. */ :root { color-scheme: light; } :root[data-mode="dark"] { color-scheme: dark; } @media (prefers-color-scheme: dark) { :root:not([data-mode="light"]) { color-scheme: dark; } } `; PALETTES.forEach(([id, seed], i) => { const sel = i === 0 ? ':root' : `:root[data-palette="${id}"]`; css += ` /* ${id} (seed ${seed}) */ ${sel} { ${roles(seed, false)} } ${sel}[data-mode="dark"] { ${roles(seed, true)} } @media (prefers-color-scheme: dark) { ${sel}:not([data-mode="light"]) { ${roles(seed, true)} } } `; }); // Paths are relative to tools/palettes (npm runs scripts there); the bundle itself lives in .build/. const theme = resolve(process.cwd(), '../../tonalmine'); const out = `${theme}/stylesheets/palettes.css`; writeFileSync(out, css); // Keep theme.js in sync: every palette id must appear in its PALETTES list. const js = readFileSync(`${theme}/javascripts/theme.js`, 'utf8'); const missing = PALETTES.map(([id]) => id).filter((id) => !js.includes(`'${id}'`)); if (missing.length) { console.error(`theme.js is missing palettes: ${missing.join(', ')}`); process.exit(1); } console.log(`Wrote ${PALETTES.length} palettes to ${out}`);