fluxer/fluxer_app/scripts/GenerateColorSystem.tsx
2026-02-17 12:22:36 +00:00

681 lines
29 KiB
TypeScript

/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {mkdirSync, writeFileSync} from 'node:fs';
import {dirname, join, relative} from 'node:path';
interface ColorFamily {
hue: number;
saturation: number;
useSaturationFactor: boolean;
}
interface ScaleStop {
name: string;
position?: number;
}
interface Scale {
family: string;
range: [number, number];
curve: 'linear' | 'easeIn' | 'easeOut' | 'easeInOut';
stops: Array<ScaleStop>;
}
interface TokenDef {
name?: string;
scale?: string;
value?: string;
family?: string;
hue?: number;
saturation?: number;
lightness?: number;
alpha?: number;
useSaturationFactor?: boolean;
}
interface Config {
families: Record<string, ColorFamily>;
scales: Record<string, Scale>;
tokens: {
root: Array<TokenDef>;
light: Array<TokenDef>;
coal: Array<TokenDef>;
};
}
const CONFIG: Config = {
families: {
neutralDark: {hue: 220, saturation: 13, useSaturationFactor: true},
neutralLight: {hue: 220, saturation: 10, useSaturationFactor: true},
brand: {hue: 242, saturation: 70, useSaturationFactor: true},
link: {hue: 210, saturation: 100, useSaturationFactor: true},
accentPurple: {hue: 270, saturation: 80, useSaturationFactor: true},
statusOnline: {hue: 142, saturation: 76, useSaturationFactor: true},
statusIdle: {hue: 45, saturation: 93, useSaturationFactor: true},
statusDnd: {hue: 0, saturation: 84, useSaturationFactor: true},
statusOffline: {hue: 218, saturation: 11, useSaturationFactor: true},
statusDanger: {hue: 1, saturation: 77, useSaturationFactor: true},
textCode: {hue: 340, saturation: 50, useSaturationFactor: true},
brandIcon: {hue: 38, saturation: 92, useSaturationFactor: true},
},
scales: {
darkSurface: {
family: 'neutralDark',
range: [5, 26],
curve: 'easeOut',
stops: [
{name: '--background-primary', position: 0},
{name: '--background-secondary', position: 0.16},
{name: '--background-secondary-lighter', position: 0.22},
{name: '--background-secondary-alt', position: 0.28},
{name: '--background-tertiary', position: 0.4},
{name: '--background-channel-header', position: 0.34},
{name: '--guild-list-foreground', position: 0.38},
{name: '--background-header-secondary', position: 0.5},
{name: '--background-header-primary', position: 0.5},
{name: '--background-textarea', position: 0.68},
{name: '--background-header-primary-hover', position: 0.85},
],
},
coalSurface: {
family: 'neutralDark',
range: [1, 12],
curve: 'easeOut',
stops: [
{name: '--background-primary', position: 0},
{name: '--background-secondary', position: 0.16},
{name: '--background-secondary-alt', position: 0.28},
{name: '--background-tertiary', position: 0.4},
{name: '--background-channel-header', position: 0.34},
{name: '--guild-list-foreground', position: 0.38},
{name: '--background-header-secondary', position: 0.5},
{name: '--background-header-primary', position: 0.5},
{name: '--background-textarea', position: 0.68},
{name: '--background-header-primary-hover', position: 0.85},
],
},
darkText: {
family: 'neutralDark',
range: [52, 96],
curve: 'easeInOut',
stops: [
{name: '--text-tertiary-secondary', position: 0},
{name: '--text-tertiary-muted', position: 0.2},
{name: '--text-tertiary', position: 0.38},
{name: '--text-primary-muted', position: 0.55},
{name: '--text-chat-muted', position: 0.55},
{name: '--text-secondary', position: 0.72},
{name: '--text-chat', position: 0.82},
{name: '--text-primary', position: 1},
],
},
lightSurface: {
family: 'neutralLight',
range: [86, 98.5],
curve: 'easeIn',
stops: [
{name: '--background-header-primary-hover', position: 0},
{name: '--background-header-primary', position: 0.12},
{name: '--background-header-secondary', position: 0.2},
{name: '--guild-list-foreground', position: 0.35},
{name: '--background-tertiary', position: 0.42},
{name: '--background-channel-header', position: 0.5},
{name: '--background-secondary-alt', position: 0.63},
{name: '--background-secondary', position: 0.74},
{name: '--background-secondary-lighter', position: 0.83},
{name: '--background-textarea', position: 0.88},
{name: '--background-primary', position: 1},
],
},
lightText: {
family: 'neutralLight',
range: [15, 60],
curve: 'easeOut',
stops: [
{name: '--text-primary', position: 0},
{name: '--text-chat', position: 0.08},
{name: '--text-secondary', position: 0.28},
{name: '--text-chat-muted', position: 0.45},
{name: '--text-primary-muted', position: 0.45},
{name: '--text-tertiary', position: 0.6},
{name: '--text-tertiary-secondary', position: 0.75},
{name: '--text-tertiary-muted', position: 0.85},
],
},
},
tokens: {
root: [
{scale: 'darkSurface'},
{scale: 'darkText'},
{
name: '--panel-control-bg',
value: `color-mix(
in srgb,
var(--background-secondary-alt) 80%,
hsl(220, calc(13% * var(--saturation-factor)), 2%) 20%
)`,
},
{name: '--panel-control-border', family: 'neutralDark', saturation: 30, lightness: 65, alpha: 0.45},
{name: '--panel-control-divider', family: 'neutralDark', saturation: 30, lightness: 55, alpha: 0.35},
{name: '--panel-control-highlight', value: 'hsla(0, 0%, 100%, 0.04)'},
{name: '--background-modifier-hover', family: 'neutralDark', lightness: 100, alpha: 0.05},
{name: '--background-modifier-selected', family: 'neutralDark', lightness: 100, alpha: 0.1},
{name: '--background-modifier-accent', family: 'neutralDark', saturation: 13, lightness: 80, alpha: 0.15},
{name: '--background-modifier-accent-focus', family: 'neutralDark', saturation: 13, lightness: 80, alpha: 0.22},
{name: '--control-button-normal-bg', value: 'transparent'},
{name: '--control-button-normal-text', value: 'var(--text-primary-muted)'},
{name: '--control-button-hover-bg', family: 'neutralDark', lightness: 22},
{name: '--control-button-hover-text', value: 'var(--text-primary)'},
{name: '--control-button-active-bg', family: 'neutralDark', lightness: 24},
{name: '--control-button-active-text', value: 'var(--text-primary)'},
{name: '--control-button-danger-text', hue: 1, saturation: 77, useSaturationFactor: true, lightness: 60},
{name: '--control-button-danger-hover-bg', hue: 1, saturation: 77, useSaturationFactor: true, lightness: 20},
{name: '--brand-primary', family: 'brand', lightness: 55},
{name: '--brand-secondary', family: 'brand', saturation: 60, lightness: 49},
{name: '--brand-primary-light', family: 'brand', saturation: 100, lightness: 84},
{name: '--brand-primary-fill', hue: 0, saturation: 0, lightness: 100},
{name: '--status-online', family: 'statusOnline', lightness: 40},
{name: '--status-idle', family: 'statusIdle', lightness: 50},
{name: '--status-dnd', family: 'statusDnd', lightness: 60},
{name: '--status-offline', family: 'statusOffline', lightness: 65},
{name: '--status-danger', family: 'statusDanger', lightness: 55},
{name: '--status-warning', value: 'var(--status-idle)'},
{name: '--text-warning', family: 'statusIdle', lightness: 55},
{name: '--plutonium', value: 'var(--brand-primary)'},
{name: '--plutonium-hover', value: 'var(--brand-secondary)'},
{name: '--plutonium-text', value: 'var(--text-on-brand-primary)'},
{name: '--plutonium-icon', family: 'brandIcon', lightness: 50},
{name: '--invite-verified-icon-color', value: 'var(--text-on-brand-primary)'},
{name: '--text-link', family: 'link', lightness: 70},
{name: '--text-on-brand-primary', hue: 0, saturation: 0, lightness: 98},
{name: '--text-code', family: 'textCode', lightness: 90},
{name: '--text-selection', hue: 210, saturation: 90, useSaturationFactor: true, lightness: 70, alpha: 0.35},
{name: '--markup-mention-text', value: 'var(--text-link)'},
{name: '--markup-mention-fill', value: 'color-mix(in srgb, var(--text-link) 20%, transparent)'},
{name: '--markup-mention-border', family: 'link', lightness: 70, alpha: 0.3},
{name: '--markup-jump-link-text', value: 'var(--text-link)'},
{name: '--markup-jump-link-fill', value: 'color-mix(in srgb, var(--text-link) 12%, transparent)'},
{name: '--markup-jump-link-hover-fill', value: 'color-mix(in srgb, var(--text-link) 20%, transparent)'},
{name: '--markup-everyone-text', hue: 250, saturation: 80, useSaturationFactor: true, lightness: 75},
{
name: '--markup-everyone-fill',
value: 'color-mix(in srgb, hsl(250, calc(80% * var(--saturation-factor)), 75%) 18%, transparent)',
},
{
name: '--markup-everyone-border',
hue: 250,
saturation: 80,
useSaturationFactor: true,
lightness: 75,
alpha: 0.3,
},
{name: '--markup-here-text', hue: 45, saturation: 90, useSaturationFactor: true, lightness: 70},
{
name: '--markup-here-fill',
value: 'color-mix(in srgb, hsl(45, calc(90% * var(--saturation-factor)), 70%) 18%, transparent)',
},
{name: '--markup-here-border', hue: 45, saturation: 90, useSaturationFactor: true, lightness: 70, alpha: 0.3},
{name: '--markup-interactive-hover-text', value: 'var(--text-link)'},
{name: '--markup-interactive-hover-fill', value: 'color-mix(in srgb, var(--text-link) 30%, transparent)'},
{
name: '--interactive-muted',
value: `color-mix(
in oklab,
hsl(228, calc(10% * var(--saturation-factor)), 35%) 100%,
hsl(245, calc(100% * var(--saturation-factor)), 80%) 40%
)`,
},
{
name: '--interactive-active',
value: `color-mix(
in oklab,
hsl(0, calc(0% * var(--saturation-factor)), 100%) 100%,
hsl(245, calc(100% * var(--saturation-factor)), 80%) 40%
)`,
},
{name: '--button-primary-fill', hue: 139, saturation: 55, useSaturationFactor: true, lightness: 44},
{name: '--button-primary-active-fill', hue: 136, saturation: 60, useSaturationFactor: true, lightness: 38},
{name: '--button-primary-text', hue: 0, saturation: 0, lightness: 100},
{name: '--button-secondary-fill', hue: 0, saturation: 0, lightness: 100, alpha: 0.1, useSaturationFactor: false},
{
name: '--button-secondary-active-fill',
hue: 0,
saturation: 0,
lightness: 100,
alpha: 0.15,
useSaturationFactor: false,
},
{name: '--button-secondary-text', hue: 0, saturation: 0, lightness: 100},
{name: '--button-secondary-active-text', value: 'var(--button-secondary-text)'},
{name: '--button-danger-fill', hue: 359, saturation: 70, useSaturationFactor: true, lightness: 54},
{name: '--button-danger-active-fill', hue: 359, saturation: 65, useSaturationFactor: true, lightness: 45},
{name: '--button-danger-text', hue: 0, saturation: 0, lightness: 100},
{name: '--button-danger-outline-border', value: '1px solid hsl(359, calc(70% * var(--saturation-factor)), 54%)'},
{name: '--button-danger-outline-text', hue: 0, saturation: 0, lightness: 100},
{name: '--button-danger-outline-active-fill', hue: 359, saturation: 65, useSaturationFactor: true, lightness: 48},
{name: '--button-danger-outline-active-border', value: 'transparent'},
{name: '--button-ghost-text', hue: 0, saturation: 0, lightness: 100},
{name: '--button-inverted-fill', hue: 0, saturation: 0, lightness: 100},
{name: '--button-inverted-text', hue: 0, saturation: 0, lightness: 0},
{name: '--button-outline-border', value: '1px solid hsla(0, 0%, 100%, 0.3)'},
{name: '--button-outline-text', hue: 0, saturation: 0, lightness: 100},
{name: '--button-outline-active-fill', value: 'hsla(0, 0%, 100%, 0.15)'},
{name: '--button-outline-active-border', value: '1px solid hsla(0, 0%, 100%, 0.4)'},
{name: '--theme-border', value: 'transparent'},
{name: '--theme-border-width', value: '0px'},
{name: '--bg-primary', value: 'var(--background-primary)'},
{name: '--bg-secondary', value: 'var(--background-secondary)'},
{name: '--bg-tertiary', value: 'var(--background-tertiary)'},
{name: '--bg-hover', value: 'var(--background-modifier-hover)'},
{name: '--bg-active', value: 'var(--background-modifier-selected)'},
{name: '--bg-code', family: 'neutralDark', lightness: 15, alpha: 0.8},
{name: '--bg-code-block', value: 'var(--background-secondary-alt)'},
{name: '--bg-blockquote', value: 'var(--background-secondary-alt)'},
{name: '--bg-table-header', value: 'var(--background-tertiary)'},
{name: '--bg-table-row-odd', value: 'var(--background-primary)'},
{name: '--bg-table-row-even', value: 'var(--background-secondary)'},
{name: '--border-color', family: 'neutralDark', lightness: 50, alpha: 0.2},
{name: '--border-color-hover', family: 'neutralDark', lightness: 50, alpha: 0.3},
{name: '--border-color-focus', hue: 210, saturation: 90, useSaturationFactor: true, lightness: 70, alpha: 0.45},
{name: '--accent-primary', value: 'var(--brand-primary)'},
{name: '--accent-success', value: 'var(--status-online)'},
{name: '--accent-warning', value: 'var(--status-idle)'},
{name: '--accent-danger', value: 'var(--status-dnd)'},
{name: '--accent-info', value: 'var(--text-link)'},
{name: '--accent-purple', family: 'accentPurple', lightness: 65},
{name: '--alert-note-color', family: 'link', lightness: 70},
{name: '--alert-tip-color', family: 'statusOnline', lightness: 45},
{name: '--alert-important-color', family: 'accentPurple', lightness: 65},
{name: '--alert-warning-color', family: 'statusIdle', lightness: 55},
{name: '--alert-caution-color', hue: 359, saturation: 75, useSaturationFactor: true, lightness: 60},
{name: '--shadow-sm', value: '0 1px 2px rgba(0, 0, 0, 0.1)'},
{name: '--shadow-md', value: '0 2px 4px rgba(0, 0, 0, 0.15), 0 1px 2px rgba(0, 0, 0, 0.1)'},
{name: '--shadow-lg', value: '0 4px 8px rgba(0, 0, 0, 0.15), 0 2px 4px rgba(0, 0, 0, 0.1)'},
{name: '--shadow-xl', value: '0 10px 20px rgba(0, 0, 0, 0.15), 0 4px 8px rgba(0, 0, 0, 0.1)'},
{name: '--transition-fast', value: '100ms ease'},
{name: '--transition-normal', value: '200ms ease'},
{name: '--transition-slow', value: '300ms ease'},
{name: '--spoiler-overlay-color', value: 'rgba(0, 0, 0, 0.2)'},
{name: '--spoiler-overlay-hover-color', value: 'rgba(0, 0, 0, 0.3)'},
{name: '--scrollbar-thumb-bg', value: 'rgba(121, 122, 124, 0.4)'},
{name: '--scrollbar-thumb-bg-hover', value: 'rgba(121, 122, 124, 0.7)'},
{name: '--scrollbar-track-bg', value: 'transparent'},
{
name: '--user-area-divider-color',
value: 'color-mix(in srgb, var(--background-modifier-hover) 70%, transparent)',
},
],
light: [
{scale: 'lightSurface'},
{scale: 'lightText'},
{name: '--panel-control-bg', value: 'color-mix(in srgb, var(--background-secondary) 65%, hsl(0, 0%, 100%) 35%)'},
{name: '--panel-control-border', family: 'neutralLight', saturation: 25, lightness: 45, alpha: 0.25},
{name: '--panel-control-divider', family: 'neutralLight', saturation: 30, lightness: 35, alpha: 0.2},
{name: '--panel-control-highlight', value: 'hsla(0, 0%, 100%, 0.65)'},
{name: '--background-modifier-hover', family: 'neutralLight', saturation: 10, lightness: 10, alpha: 0.05},
{name: '--background-modifier-selected', family: 'neutralLight', saturation: 10, lightness: 10, alpha: 0.1},
{name: '--background-modifier-accent', family: 'neutralLight', saturation: 10, lightness: 40, alpha: 0.22},
{name: '--background-modifier-accent-focus', family: 'neutralLight', saturation: 10, lightness: 40, alpha: 0.32},
{name: '--control-button-normal-bg', value: 'transparent'},
{name: '--control-button-normal-text', family: 'neutralLight', lightness: 50},
{name: '--control-button-hover-bg', family: 'neutralLight', lightness: 88},
{name: '--control-button-hover-text', family: 'neutralLight', lightness: 20},
{name: '--control-button-active-bg', family: 'neutralLight', lightness: 85},
{name: '--control-button-active-text', family: 'neutralLight', lightness: 15},
{name: '--control-button-danger-text', hue: 359, saturation: 70, useSaturationFactor: true, lightness: 50},
{name: '--control-button-danger-hover-bg', hue: 359, saturation: 70, useSaturationFactor: true, lightness: 95},
{name: '--text-link', family: 'link', lightness: 45},
{name: '--text-code', family: 'textCode', lightness: 45},
{name: '--text-selection', hue: 210, saturation: 90, useSaturationFactor: true, lightness: 50, alpha: 0.2},
{name: '--markup-mention-border', family: 'link', lightness: 45, alpha: 0.4},
{name: '--markup-jump-link-fill', value: 'color-mix(in srgb, var(--text-link) 8%, transparent)'},
{name: '--markup-everyone-text', hue: 250, saturation: 70, useSaturationFactor: true, lightness: 45},
{
name: '--markup-everyone-fill',
value: 'color-mix(in srgb, hsl(250, calc(70% * var(--saturation-factor)), 45%) 12%, transparent)',
},
{
name: '--markup-everyone-border',
hue: 250,
saturation: 70,
useSaturationFactor: true,
lightness: 45,
alpha: 0.4,
},
{name: '--markup-here-text', hue: 40, saturation: 85, useSaturationFactor: true, lightness: 40},
{
name: '--markup-here-fill',
value: 'color-mix(in srgb, hsl(40, calc(85% * var(--saturation-factor)), 40%) 12%, transparent)',
},
{name: '--markup-here-border', hue: 40, saturation: 85, useSaturationFactor: true, lightness: 40, alpha: 0.4},
{name: '--status-online', family: 'statusOnline', saturation: 70, lightness: 40},
{name: '--status-idle', family: 'statusIdle', saturation: 90, lightness: 45},
{name: '--status-dnd', hue: 359, saturation: 70, useSaturationFactor: true, lightness: 50},
{name: '--status-offline', family: 'statusOffline', hue: 210, saturation: 10, lightness: 55},
{name: '--plutonium', value: 'var(--brand-primary)'},
{name: '--plutonium-hover', value: 'var(--brand-secondary)'},
{name: '--plutonium-text', value: 'var(--text-on-brand-primary)'},
{name: '--plutonium-icon', family: 'brandIcon', lightness: 45},
{name: '--invite-verified-icon-color', value: 'var(--brand-primary)'},
{name: '--border-color', family: 'neutralLight', lightness: 40, alpha: 0.15},
{name: '--border-color-hover', family: 'neutralLight', lightness: 40, alpha: 0.25},
{name: '--border-color-focus', hue: 210, saturation: 90, useSaturationFactor: true, lightness: 50, alpha: 0.4},
{name: '--bg-primary', value: 'var(--background-primary)'},
{name: '--bg-secondary', value: 'var(--background-secondary)'},
{name: '--bg-tertiary', value: 'var(--background-tertiary)'},
{name: '--bg-hover', value: 'var(--background-modifier-hover)'},
{name: '--bg-active', value: 'var(--background-modifier-selected)'},
{name: '--bg-code', family: 'neutralLight', saturation: 22, lightness: 90, alpha: 0.9},
{name: '--bg-code-block', value: 'var(--background-primary)'},
{name: '--bg-blockquote', value: 'var(--background-secondary-alt)'},
{name: '--bg-table-header', value: 'var(--background-tertiary)'},
{name: '--bg-table-row-odd', value: 'var(--background-primary)'},
{name: '--bg-table-row-even', value: 'var(--background-secondary)'},
{name: '--alert-note-color', family: 'link', lightness: 45},
{name: '--alert-tip-color', hue: 150, saturation: 80, useSaturationFactor: true, lightness: 35},
{name: '--alert-important-color', family: 'accentPurple', lightness: 50},
{name: '--alert-warning-color', family: 'statusIdle', saturation: 90, lightness: 45},
{name: '--alert-caution-color', hue: 358, saturation: 80, useSaturationFactor: true, lightness: 50},
{name: '--spoiler-overlay-color', value: 'rgba(0, 0, 0, 0.1)'},
{name: '--spoiler-overlay-hover-color', value: 'rgba(0, 0, 0, 0.15)'},
{name: '--button-secondary-fill', family: 'neutralLight', saturation: 10, lightness: 10, alpha: 0.1},
{name: '--button-secondary-active-fill', family: 'neutralLight', saturation: 10, lightness: 10, alpha: 0.15},
{name: '--button-secondary-text', family: 'neutralLight', lightness: 15},
{name: '--button-secondary-active-text', family: 'neutralLight', lightness: 10},
{name: '--button-ghost-text', family: 'neutralLight', lightness: 20},
{name: '--button-inverted-fill', hue: 0, saturation: 0, lightness: 100},
{name: '--button-inverted-text', hue: 0, saturation: 0, lightness: 10},
{name: '--button-outline-border', value: '1px solid hsla(220, calc(10% * var(--saturation-factor)), 40%, 0.3)'},
{name: '--button-outline-text', family: 'neutralLight', lightness: 20},
{name: '--button-outline-active-fill', family: 'neutralLight', saturation: 10, lightness: 10, alpha: 0.1},
{
name: '--button-outline-active-border',
value: '1px solid hsla(220, calc(10% * var(--saturation-factor)), 40%, 0.5)',
},
{name: '--button-danger-outline-border', value: '1px solid hsl(359, calc(70% * var(--saturation-factor)), 50%)'},
{name: '--button-danger-outline-text', hue: 359, saturation: 70, useSaturationFactor: true, lightness: 45},
{name: '--button-danger-outline-active-fill', hue: 359, saturation: 70, useSaturationFactor: true, lightness: 50},
{name: '--user-area-divider-color', family: 'neutralLight', lightness: 40, alpha: 0.2},
],
coal: [
{scale: 'coalSurface'},
{name: '--background-secondary', value: 'var(--background-primary)'},
{name: '--background-secondary-lighter', value: 'var(--background-primary)'},
{
name: '--panel-control-bg',
value: `color-mix(
in srgb,
var(--background-primary) 90%,
hsl(220, calc(13% * var(--saturation-factor)), 0%) 10%
)`,
},
{name: '--panel-control-border', family: 'neutralDark', saturation: 20, lightness: 30, alpha: 0.35},
{name: '--panel-control-divider', family: 'neutralDark', saturation: 20, lightness: 25, alpha: 0.28},
{name: '--panel-control-highlight', value: 'hsla(0, 0%, 100%, 0.06)'},
{name: '--background-modifier-hover', family: 'neutralDark', lightness: 100, alpha: 0.04},
{name: '--background-modifier-selected', family: 'neutralDark', lightness: 100, alpha: 0.08},
{name: '--background-modifier-accent', family: 'neutralDark', saturation: 10, lightness: 65, alpha: 0.18},
{name: '--background-modifier-accent-focus', family: 'neutralDark', saturation: 10, lightness: 70, alpha: 0.26},
{name: '--control-button-normal-bg', value: 'transparent'},
{name: '--control-button-normal-text', value: 'var(--text-primary-muted)'},
{name: '--control-button-hover-bg', family: 'neutralDark', lightness: 12},
{name: '--control-button-hover-text', value: 'var(--text-primary)'},
{name: '--control-button-active-bg', family: 'neutralDark', lightness: 14},
{name: '--control-button-active-text', value: 'var(--text-primary)'},
{name: '--scrollbar-thumb-bg', value: 'rgba(160, 160, 160, 0.35)'},
{name: '--scrollbar-thumb-bg-hover', value: 'rgba(200, 200, 200, 0.55)'},
{name: '--scrollbar-track-bg', value: 'rgba(0, 0, 0, 0.45)'},
{name: '--bg-primary', value: 'var(--background-primary)'},
{name: '--bg-secondary', value: 'var(--background-secondary)'},
{name: '--bg-tertiary', value: 'var(--background-tertiary)'},
{name: '--bg-hover', value: 'var(--background-modifier-hover)'},
{name: '--bg-active', value: 'var(--background-modifier-selected)'},
{name: '--bg-code', value: 'hsl(220, calc(13% * var(--saturation-factor)), 8%)'},
{name: '--bg-code-block', value: 'var(--background-secondary-alt)'},
{name: '--bg-blockquote', value: 'var(--background-secondary)'},
{name: '--bg-table-header', value: 'var(--background-tertiary)'},
{name: '--bg-table-row-odd', value: 'var(--background-primary)'},
{name: '--bg-table-row-even', value: 'var(--background-secondary)'},
{name: '--button-secondary-fill', value: 'hsla(0, 0%, 100%, 0.04)'},
{name: '--button-secondary-active-fill', value: 'hsla(0, 0%, 100%, 0.07)'},
{name: '--button-secondary-text', value: 'var(--text-primary)'},
{name: '--button-secondary-active-text', value: 'var(--text-primary)'},
{name: '--button-outline-border', value: '1px solid hsla(0, 0%, 100%, 0.08)'},
{name: '--button-outline-active-fill', value: 'hsla(0, 0%, 100%, 0.12)'},
{name: '--button-outline-active-border', value: '1px solid hsla(0, 0%, 100%, 0.16)'},
{
name: '--user-area-divider-color',
value: 'color-mix(in srgb, var(--background-modifier-hover) 80%, transparent)',
},
],
},
};
interface OutputToken {
type: 'tone' | 'literal';
name: string;
family?: string;
hue?: number;
saturation?: number;
lightness?: number;
alpha?: number;
useSaturationFactor?: boolean;
value?: string;
}
function clamp01(value: number): number {
return Math.min(1, Math.max(0, value));
}
function applyCurve(curve: Scale['curve'], t: number): number {
switch (curve) {
case 'easeIn':
return t * t;
case 'easeOut':
return 1 - (1 - t) * (1 - t);
case 'easeInOut':
if (t < 0.5) {
return 2 * t * t;
}
return 1 - 2 * (1 - t) * (1 - t);
default:
return t;
}
}
function buildScaleTokens(scale: Scale): Array<OutputToken> {
const lastIndex = Math.max(scale.stops.length - 1, 1);
const tokens: Array<OutputToken> = [];
for (let i = 0; i < scale.stops.length; i++) {
const stop = scale.stops[i];
let pos: number;
if (stop.position !== undefined) {
pos = clamp01(stop.position);
} else {
pos = i / lastIndex;
}
const eased = applyCurve(scale.curve, pos);
let lightness = scale.range[0] + (scale.range[1] - scale.range[0]) * eased;
lightness = Math.round(lightness * 1000) / 1000;
tokens.push({
type: 'tone',
name: stop.name,
family: scale.family,
lightness,
});
}
return tokens;
}
function expandTokens(defs: Array<TokenDef>, scales: Record<string, Scale>): Array<OutputToken> {
const tokens: Array<OutputToken> = [];
for (const def of defs) {
if (def.scale) {
const scale = scales[def.scale];
if (!scale) {
console.warn(`Warning: unknown scale "${def.scale}"`);
continue;
}
tokens.push(...buildScaleTokens(scale));
continue;
}
if (def.value !== undefined) {
tokens.push({
type: 'literal',
name: def.name!,
value: def.value.trim(),
});
} else {
tokens.push({
type: 'tone',
name: def.name!,
family: def.family,
hue: def.hue,
saturation: def.saturation,
lightness: def.lightness,
alpha: def.alpha,
useSaturationFactor: def.useSaturationFactor,
});
}
}
return tokens;
}
function formatNumber(value: number): string {
if (value === Math.floor(value)) {
return String(Math.floor(value));
}
let s = value.toFixed(2);
s = s.replace(/\.?0+$/, '');
return s;
}
function formatTone(token: OutputToken, families: Record<string, ColorFamily>): string {
const family = token.family ? families[token.family] : undefined;
let hue = 0;
let saturation = 0;
let lightness = 0;
let useFactor = false;
if (token.hue !== undefined) {
hue = token.hue;
} else if (family) {
hue = family.hue;
}
if (token.saturation !== undefined) {
saturation = token.saturation;
} else if (family) {
saturation = family.saturation;
}
if (token.lightness !== undefined) {
lightness = token.lightness;
}
if (token.useSaturationFactor !== undefined) {
useFactor = token.useSaturationFactor;
} else if (family) {
useFactor = family.useSaturationFactor;
}
let satStr: string;
if (useFactor) {
satStr = `calc(${formatNumber(saturation)}% * var(--saturation-factor))`;
} else {
satStr = `${formatNumber(saturation)}%`;
}
if (token.alpha === undefined) {
return `hsl(${formatNumber(hue)}, ${satStr}, ${formatNumber(lightness)}%)`;
}
return `hsla(${formatNumber(hue)}, ${satStr}, ${formatNumber(lightness)}%, ${formatNumber(token.alpha)})`;
}
function formatValue(token: OutputToken, families: Record<string, ColorFamily>): string {
if (token.type === 'tone') {
return formatTone(token, families);
}
return token.value!.trim();
}
function renderBlock(selector: string, tokens: Array<OutputToken>, families: Record<string, ColorFamily>): string {
const lines: Array<string> = [];
for (const token of tokens) {
lines.push(`\t${token.name}: ${formatValue(token, families)};`);
}
return `${selector} {\n${lines.join('\n')}\n}`;
}
function generateCSS(
cfg: Config,
rootTokens: Array<OutputToken>,
lightTokens: Array<OutputToken>,
coalTokens: Array<OutputToken>,
): string {
const header = `/*
* This file is auto-generated by scripts/GenerateColorSystem.ts.
* Do not edit directly — update the config in generate-color-system.ts instead.
*/`;
const blocks = [
renderBlock(':root', rootTokens, cfg.families),
renderBlock('.theme-light', lightTokens, cfg.families),
renderBlock('.theme-coal', coalTokens, cfg.families),
];
return `${header}\n\n${blocks.join('\n\n')}\n`;
}
function main() {
const scriptDir = import.meta.dirname;
const appDir = join(scriptDir, '..');
const rootTokens = expandTokens(CONFIG.tokens.root, CONFIG.scales);
const lightTokens = expandTokens(CONFIG.tokens.light, CONFIG.scales);
const coalTokens = expandTokens(CONFIG.tokens.coal, CONFIG.scales);
const cssPath = join(appDir, 'src', 'styles', 'generated', 'color-system.css');
mkdirSync(dirname(cssPath), {recursive: true});
const css = generateCSS(CONFIG, rootTokens, lightTokens, coalTokens);
writeFileSync(cssPath, css);
const relCSS = relative(appDir, cssPath);
console.log(`Wrote ${relCSS}`);
}
main();