feat: contrast levels and custom color picker in appearance menu
Generate medium/high contrast schemes, add a custom swatch that builds a scheme in the browser via lazy-loaded scheme.js, allow Theme Builder exports as palettes. Drop the teal palette and spread the swatches across the menu width. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b4eba7ae63
commit
6c271ffffe
@@ -1,64 +1,51 @@
|
||||
// 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
|
||||
// The same build bundles scheme.mjs into tonalmine/javascripts/scheme.js for the custom color picker
|
||||
// (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';
|
||||
import { ROLES, kebab, paletteCss } from './scheme.mjs';
|
||||
|
||||
// The first palette is the default. Ids must match PALETTES in tonalmine/javascripts/theme.js.
|
||||
// An optional third entry is a Material Theme Builder export folder (light.css, dark.css and the
|
||||
// -mc/-hc contrast variants):
|
||||
// its roles are copied as-is instead of generated, and the seed only drives success/warning.
|
||||
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(' ');
|
||||
// Reads the roles from a Theme Builder export; fails if one is missing.
|
||||
function exported(dir, dark, contrast) {
|
||||
const file = `${dir}/${dark ? 'dark' : 'light'}${{ medium: '-mc', high: '-hc' }[contrast] || ''}.css`;
|
||||
const vars = Object.fromEntries([...readFileSync(file, 'utf8').matchAll(/--md-sys-color-([a-z-]+):\s*([^;]+);/g)].map((m) => [m[1], m[2].trim()]));
|
||||
return ROLES.map((r) => {
|
||||
if (!vars[kebab(r)]) { console.error(`${file} is missing --md-sys-color-${kebab(r)}`); process.exit(1); }
|
||||
return `--md-sys-color-${kebab(r)}: ${vars[kebab(r)]};`;
|
||||
});
|
||||
}
|
||||
|
||||
// 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.
|
||||
* M3 color roles (TonalSpot) per palette, light and dark, at contrast 0, 0.5 and 1.
|
||||
* <html data-palette="..."> picks the palette, <html data-mode="light|dark"> forces a mode;
|
||||
* without data-mode the OS setting (prefers-color-scheme) decides.
|
||||
* <html data-contrast="medium|high"> picks the contrast level; without it, standard.
|
||||
*/
|
||||
|
||||
: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) => {
|
||||
PALETTES.forEach(([id, seed, dir], 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)} } }
|
||||
`;
|
||||
/* ${id} (${dir ? `export ${dir}, ` : ''}seed ${seed}) */
|
||||
` + paletteCss(sel, seed, dir && ((dark, contrast) => exported(dir, dark, contrast)));
|
||||
});
|
||||
|
||||
// Paths are relative to tools/palettes (npm runs scripts there); the bundle itself lives in .build/.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "esbuild build_palettes.mjs --bundle --platform=node --format=esm --outfile=.build/palettes.mjs --log-level=warning && node .build/palettes.mjs"
|
||||
"build": "esbuild scheme.mjs --bundle --minify --format=iife --global-name=tonalmineScheme --outfile=../../tonalmine/javascripts/scheme.js --log-level=warning && esbuild build_palettes.mjs --bundle --platform=node --format=esm --outfile=.build/palettes.mjs --log-level=warning && node .build/palettes.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@material/material-color-utilities": "0.4.0",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// M3 color roles for one palette, shared by build_palettes.mjs (palettes.css) and the browser
|
||||
// (bundled as tonalmine/javascripts/scheme.js, global tonalmineScheme, for the custom color picker).
|
||||
import { Hct, SchemeTonalSpot, MaterialDynamicColors as C, hexFromArgb, argbFromHex, customColor } from '@material/material-color-utilities';
|
||||
|
||||
const CUSTOM = [['success', '#386A20'], ['warning', '#7C5800']];
|
||||
export 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'];
|
||||
|
||||
export const kebab = (s) => s.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
|
||||
const hex = (argb) => hexFromArgb(argb);
|
||||
|
||||
// Contrast levels as Material Theme Builder has them; '' is standard (no data-contrast attribute).
|
||||
export const CONTRASTS = [['', 0], ['medium', 0.5], ['high', 1]];
|
||||
|
||||
// TonalSpot roles plus success/warning; `own` replaces the generated ROLES lines.
|
||||
// ponytail: success/warning use customColor, which has no contrast level (same tones at every level).
|
||||
function roles(seedHex, dark, level, own) {
|
||||
const seed = argbFromHex(seedHex);
|
||||
const scheme = new SchemeTonalSpot(Hct.fromInt(seed), dark, level);
|
||||
const lines = own || 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(' ');
|
||||
}
|
||||
|
||||
// Light, forced dark and OS dark rules for one palette selector, per contrast level.
|
||||
// `own(dark, contrast)` may return role lines to use instead of generated ones.
|
||||
// Each contrast rule adds an attribute, so it beats the same palette's standard rules.
|
||||
export function paletteCss(sel, seedHex, own) {
|
||||
return CONTRASTS.map(([id, level]) => {
|
||||
const s = id ? `${sel}[data-contrast="${id}"]` : sel;
|
||||
const r = (dark) => roles(seedHex, dark, level, own && own(dark, id));
|
||||
return `${s} { ${r(false)} }
|
||||
${s}[data-mode="dark"] { ${r(true)} }
|
||||
@media (prefers-color-scheme: dark) { ${s}:not([data-mode="light"]) { ${r(true)} } }
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
Reference in New Issue
Block a user