work-in-progress atproto PDS
typescript atproto pds atcute

feat: color token thingy

mary.my.id 24353fff ebb4668a

verified
+1008
+20
packages/chrysalis/package.json
··· 1 + { 2 + "name": "@kelinci/chrysalis", 3 + "version": "0.0.0", 4 + "private": true, 5 + "description": "color token generation library", 6 + "type": "module", 7 + "exports": { 8 + ".": "./src/index.ts" 9 + }, 10 + "scripts": { 11 + "tsc": "tsc -b" 12 + }, 13 + "dependencies": { 14 + "culori": "^4.0.2" 15 + }, 16 + "devDependencies": { 17 + "@types/bun": "^1.3.6", 18 + "@types/culori": "^4.0.1" 19 + } 20 + }
+40
packages/chrysalis/src/css.ts
··· 1 + import type { Theme } from './types.js'; 2 + 3 + /** 4 + * converts a camelCase token name to kebab-case CSS variable name. 5 + * e.g., colorNeutralBackground1 -> --color-neutral-background-1 6 + */ 7 + function tokenToCssVar(token: string): string { 8 + return ( 9 + '--' + 10 + token 11 + .replace(/([A-Z])/g, '-$1') 12 + .replace(/(\d+)/g, '-$1') 13 + .toLowerCase() 14 + ); 15 + } 16 + 17 + /** 18 + * converts a theme to CSS with light/dark media query support. 19 + * @param theme generated theme 20 + * @returns CSS string with :root light theme and @media dark theme 21 + */ 22 + export function themeToCss(theme: Theme): string { 23 + const lines: string[] = [':root {', '\t& {']; 24 + 25 + // light theme tokens 26 + for (const [token, value] of Object.entries(theme.light)) { 27 + lines.push(`\t\t${tokenToCssVar(token)}: ${value};`); 28 + } 29 + 30 + lines.push('\t}', '', '\t@media (prefers-color-scheme: dark) {'); 31 + 32 + // dark theme tokens 33 + for (const [token, value] of Object.entries(theme.dark)) { 34 + lines.push(`\t\t${tokenToCssVar(token)}: ${value};`); 35 + } 36 + 37 + lines.push('\t}', '}', ''); 38 + 39 + return lines.join('\n'); 40 + }
+23
packages/chrysalis/src/grey.ts
··· 1 + import { formatHex, type Hsl } from 'culori'; 2 + 3 + import type { GreyRamp } from './types.js'; 4 + 5 + /** 6 + * generates a grey ramp with 49 stops (lightness 2-98% in steps of 2). 7 + * @returns grey ramp keyed by lightness percentage 8 + */ 9 + export function generateGreyRamp(): GreyRamp { 10 + const ramp: GreyRamp = {}; 11 + 12 + for (let i = 2; i <= 98; i += 2) { 13 + const color: Hsl = { 14 + mode: 'hsl', 15 + h: 0, 16 + s: 0, 17 + l: i / 100, 18 + }; 19 + ramp[i] = formatHex(color)!; 20 + } 21 + 22 + return ramp; 23 + }
+185
packages/chrysalis/src/index.test.ts
··· 1 + import { expect, test, describe } from 'bun:test'; 2 + 3 + import { 4 + generateTheme, 5 + generateGreyRamp, 6 + generateBrandRamp, 7 + generateColorRamp, 8 + themeToCss, 9 + } from './index.js'; 10 + 11 + describe('grey ramp', () => { 12 + const grey = generateGreyRamp(); 13 + 14 + test('generates 49 stops from 2 to 98', () => { 15 + expect(Object.keys(grey).length).toBe(49); 16 + expect(grey[2]).toBeDefined(); 17 + expect(grey[98]).toBeDefined(); 18 + }); 19 + 20 + test('generates greys at correct lightness', () => { 21 + expect(grey[14]).toBe('#242424'); 22 + expect(grey[26]).toBe('#424242'); 23 + expect(grey[38]).toBe('#616161'); 24 + expect(grey[74]).toBe('#bdbdbd'); 25 + expect(grey[84]).toBe('#d6d6d6'); 26 + expect(grey[68]).toBe('#adadad'); 27 + }); 28 + }); 29 + 30 + describe('color ramp', () => { 31 + const ramp = generateColorRamp('#107c10'); 32 + 33 + test('primary matches input color', () => { 34 + expect(ramp.primary).toBe('#107c10'); 35 + }); 36 + 37 + test('shades are darker than primary', () => { 38 + expect(ramp.shade10).not.toBe(ramp.primary); 39 + expect(ramp.shade50).not.toBe(ramp.primary); 40 + }); 41 + 42 + test('tints are lighter than primary', () => { 43 + expect(ramp.tint10).not.toBe(ramp.primary); 44 + expect(ramp.tint60).not.toBe(ramp.primary); 45 + }); 46 + }); 47 + 48 + describe('brand ramp', () => { 49 + const brand = generateBrandRamp('#0f6cbd'); 50 + 51 + test('generates 16 positions from 10 to 160', () => { 52 + expect(Object.keys(brand).length).toBe(16); 53 + for (let i = 10; i <= 160; i += 10) { 54 + expect(brand[i]).toBeDefined(); 55 + } 56 + }); 57 + 58 + test('position 80 is the base color', () => { 59 + expect(brand[80]).toBe('#0f6cbd'); 60 + }); 61 + 62 + test('lower positions are darker', () => { 63 + expect(brand[10]).not.toBe(brand[80]); 64 + expect(brand[40]).not.toBe(brand[80]); 65 + }); 66 + 67 + test('higher positions are lighter', () => { 68 + expect(brand[100]).not.toBe(brand[80]); 69 + expect(brand[160]).not.toBe(brand[80]); 70 + }); 71 + }); 72 + 73 + describe('theme generation', () => { 74 + const theme = generateTheme({ brand: '#0f6cbd' }); 75 + 76 + describe('light theme neutral tokens', () => { 77 + test('foreground colors use grey ramp', () => { 78 + expect(theme.light.colorNeutralForeground1).toBe('#242424'); 79 + expect(theme.light.colorNeutralForeground2).toBe('#424242'); 80 + expect(theme.light.colorNeutralForeground3).toBe('#616161'); 81 + expect(theme.light.colorNeutralForegroundDisabled).toBe('#bdbdbd'); 82 + }); 83 + 84 + test('background colors', () => { 85 + expect(theme.light.colorNeutralBackground1).toBe('#ffffff'); 86 + expect(theme.light.colorNeutralBackground2).toBe('#fafafa'); 87 + expect(theme.light.colorNeutralBackground3).toBe('#f5f5f5'); 88 + }); 89 + 90 + test('stroke colors', () => { 91 + expect(theme.light.colorNeutralStroke1).toBe('#d1d1d1'); 92 + expect(theme.light.colorNeutralStroke2).toBe('#e0e0e0'); 93 + expect(theme.light.colorNeutralStrokeAccessible).toBe('#616161'); 94 + }); 95 + 96 + test('special values', () => { 97 + expect(theme.light.colorNeutralForegroundInverted).toBe('#ffffff'); 98 + expect(theme.light.colorNeutralForegroundOnBrand).toBe('#ffffff'); 99 + expect(theme.light.colorBackgroundOverlay).toBe('rgba(0, 0, 0, 0.4)'); 100 + expect(theme.light.colorStrokeFocus2).toBe('#000000'); 101 + }); 102 + }); 103 + 104 + describe('dark theme neutral tokens', () => { 105 + test('foreground colors', () => { 106 + expect(theme.dark.colorNeutralForeground1).toBe('#ffffff'); 107 + expect(theme.dark.colorNeutralForeground2).toBe('#d6d6d6'); 108 + expect(theme.dark.colorNeutralForeground3).toBe('#adadad'); 109 + expect(theme.dark.colorNeutralForegroundDisabled).toBe('#5c5c5c'); 110 + }); 111 + 112 + test('background colors', () => { 113 + expect(theme.dark.colorNeutralBackground1).toBe('#292929'); 114 + expect(theme.dark.colorNeutralBackground2).toBe('#1f1f1f'); 115 + expect(theme.dark.colorNeutralBackground3).toBe('#141414'); 116 + }); 117 + 118 + test('stroke colors', () => { 119 + expect(theme.dark.colorNeutralStroke1).toBe('#666666'); 120 + expect(theme.dark.colorNeutralStroke2).toBe('#525252'); 121 + expect(theme.dark.colorNeutralStrokeAccessible).toBe('#adadad'); 122 + expect(theme.dark.colorNeutralStrokeDisabled).toBe('#424242'); 123 + }); 124 + }); 125 + 126 + describe('brand tokens', () => { 127 + test('light theme brand background uses brand[80]', () => { 128 + expect(theme.light.colorBrandBackground).toBe('#0f6cbd'); 129 + }); 130 + 131 + test('light theme compound brand uses brand[80]', () => { 132 + expect(theme.light.colorCompoundBrandBackground).toBe('#0f6cbd'); 133 + expect(theme.light.colorCompoundBrandForeground1).toBe('#0f6cbd'); 134 + expect(theme.light.colorCompoundBrandStroke).toBe('#0f6cbd'); 135 + }); 136 + 137 + test('dark theme brand uses different positions', () => { 138 + expect(theme.dark.colorBrandBackground).not.toBe(theme.light.colorBrandBackground); 139 + }); 140 + }); 141 + 142 + describe('status tokens', () => { 143 + test('success tokens exist', () => { 144 + expect(theme.light.colorStatusSuccessBackground1).toBeDefined(); 145 + expect(theme.light.colorStatusSuccessForeground1).toBeDefined(); 146 + expect(theme.light.colorStatusSuccessBorder1).toBeDefined(); 147 + }); 148 + 149 + test('warning tokens exist', () => { 150 + expect(theme.light.colorStatusWarningBackground1).toBeDefined(); 151 + expect(theme.light.colorStatusWarningForeground3).toBeDefined(); 152 + expect(theme.light.colorStatusWarningBorder1).toBeDefined(); 153 + }); 154 + 155 + test('danger tokens exist', () => { 156 + expect(theme.light.colorStatusDangerBackground1).toBeDefined(); 157 + expect(theme.light.colorStatusDangerForeground1).toBeDefined(); 158 + expect(theme.light.colorStatusDangerBorder1).toBeDefined(); 159 + }); 160 + }); 161 + }); 162 + 163 + describe('CSS output', () => { 164 + const theme = generateTheme({ brand: '#0f6cbd' }); 165 + const css = themeToCss(theme); 166 + 167 + test('outputs :root selector', () => { 168 + expect(css).toContain(':root {'); 169 + }); 170 + 171 + test('outputs light theme tokens', () => { 172 + expect(css).toContain('--color-neutral-foreground-1: #242424'); 173 + expect(css).toContain('--color-brand-background: #0f6cbd'); 174 + }); 175 + 176 + test('outputs dark media query', () => { 177 + expect(css).toContain('@media (prefers-color-scheme: dark)'); 178 + }); 179 + 180 + test('converts camelCase to kebab-case', () => { 181 + expect(css).toContain('--color-neutral-foreground-1'); 182 + expect(css).toContain('--color-brand-background'); 183 + expect(css).toContain('--color-compound-brand-foreground-1'); 184 + }); 185 + });
+5
packages/chrysalis/src/index.ts
··· 1 + export type { BrandRamp, ColorRamp, GreyRamp, Theme, ThemeConfig } from './types.js'; 2 + export { generateColorRamp, generateBrandRamp } from './ramp.js'; 3 + export { generateGreyRamp } from './grey.js'; 4 + export { generateTheme } from './tokens/index.js'; 5 + export { themeToCss } from './css.js';
+122
packages/chrysalis/src/ramp.ts
··· 1 + import { formatHex, hsv as toHsv, type Hsv } from 'culori'; 2 + import { convertHsvToRgb } from 'culori/fn'; 3 + 4 + import type { BrandRamp, ColorRamp } from './types.js'; 5 + 6 + /** 7 + * generates a color ramp with 12 stops from a base color. 8 + * shades are generated by reducing value, tints by reducing saturation and expanding value. 9 + * @param baseColor base color in hex format 10 + * @returns color ramp with shade50-shade10, primary, tint10-tint60 11 + */ 12 + export function generateColorRamp(baseColor: string): ColorRamp { 13 + const hsv = toHsv(baseColor); 14 + if (!hsv) { 15 + throw new Error(`invalid color: ${baseColor}`); 16 + } 17 + 18 + // shade factors: [0.1, 0.24, 0.44, 0.7, 0.84] for shade10-shade50 19 + const shadeFactors = [0.1, 0.24, 0.44, 0.7, 0.84]; 20 + // tint factors: [0.12, 0.24, 0.4, 0.7, 0.84, 0.96] for tint10-tint60 21 + const tintFactors = [0.12, 0.24, 0.4, 0.7, 0.84, 0.96]; 22 + 23 + const shades = shadeFactors.map((factor) => generateShade(hsv, factor)); 24 + const tints = tintFactors.map((factor) => generateTint(hsv, factor)); 25 + 26 + return { 27 + shade50: shades[4], 28 + shade40: shades[3], 29 + shade30: shades[2], 30 + shade20: shades[1], 31 + shade10: shades[0], 32 + primary: formatHex(hsv)!, 33 + tint10: tints[0], 34 + tint20: tints[1], 35 + tint30: tints[2], 36 + tint40: tints[3], 37 + tint50: tints[4], 38 + tint60: tints[5], 39 + }; 40 + } 41 + 42 + /** 43 + * generates a brand ramp with 16 stops from a base color. 44 + * positions 10-160 in steps of 10. 45 + * @param baseColor base color in hex format 46 + * @returns brand ramp keyed by position (10-160) 47 + */ 48 + export function generateBrandRamp(baseColor: string): BrandRamp { 49 + const hsv = toHsv(baseColor); 50 + if (!hsv) { 51 + throw new Error(`invalid color: ${baseColor}`); 52 + } 53 + 54 + const ramp: BrandRamp = {}; 55 + 56 + // positions 10-80 are shades (darker), 80 is primary, 90-160 are tints (lighter) 57 + // shade factors mapped to positions 10-70 (7 shades) 58 + // position 80 = primary 59 + // tint factors mapped to positions 90-160 (8 tints) 60 + 61 + // shade factors for positions 10-70 (darkest to lightest shade) 62 + const shadeFactors: Record<number, number> = { 63 + 10: 0.96, 64 + 20: 0.84, 65 + 30: 0.7, 66 + 40: 0.52, 67 + 50: 0.36, 68 + 60: 0.24, 69 + 70: 0.12, 70 + }; 71 + 72 + // tint factors for positions 90-160 (lightest to most faded) 73 + const tintFactors: Record<number, number> = { 74 + 90: 0.08, 75 + 100: 0.16, 76 + 110: 0.28, 77 + 120: 0.4, 78 + 130: 0.52, 79 + 140: 0.68, 80 + 150: 0.84, 81 + 160: 0.96, 82 + }; 83 + 84 + for (let i = 10; i <= 70; i += 10) { 85 + ramp[i] = generateShade(hsv, shadeFactors[i]); 86 + } 87 + 88 + ramp[80] = formatHex(hsv)!; 89 + 90 + for (let i = 90; i <= 160; i += 10) { 91 + ramp[i] = generateTint(hsv, tintFactors[i]); 92 + } 93 + 94 + return ramp; 95 + } 96 + 97 + /** 98 + * generates a shade (darker) by reducing value. 99 + */ 100 + function generateShade(hsv: Hsv, factor: number): string { 101 + const shaded: Hsv = { 102 + mode: 'hsv', 103 + h: hsv.h, 104 + s: hsv.s, 105 + v: (hsv.v ?? 1) * (1 - factor), 106 + }; 107 + return formatHex(convertHsvToRgb(shaded))!; 108 + } 109 + 110 + /** 111 + * generates a tint (lighter) by reducing saturation and expanding value. 112 + */ 113 + function generateTint(hsv: Hsv, factor: number): string { 114 + const v = hsv.v ?? 1; 115 + const tinted: Hsv = { 116 + mode: 'hsv', 117 + h: hsv.h, 118 + s: (hsv.s ?? 0) * (1 - factor), 119 + v: v + (1 - v) * factor, 120 + }; 121 + return formatHex(convertHsvToRgb(tinted))!; 122 + }
+127
packages/chrysalis/src/tokens/brand.ts
··· 1 + import type { BrandRamp } from '../types.js'; 2 + 3 + /** 4 + * generates brand semantic tokens from a brand ramp. 5 + * @param brand brand ramp 6 + * @returns light and dark brand token mappings 7 + */ 8 + export function generateBrandTokens(brand: BrandRamp): { 9 + light: Record<string, string>; 10 + dark: Record<string, string>; 11 + } { 12 + const light: Record<string, string> = { 13 + // #region foreground 14 + colorBrandForegroundLink: brand[70], 15 + colorBrandForegroundLinkHover: brand[60], 16 + colorBrandForegroundLinkPressed: brand[40], 17 + colorBrandForegroundLinkSelected: brand[70], 18 + colorBrandForeground1: brand[80], 19 + colorBrandForeground2: brand[70], 20 + colorBrandForeground2Hover: brand[60], 21 + colorBrandForeground2Pressed: brand[30], 22 + colorBrandForegroundOnLight: brand[80], 23 + colorBrandForegroundOnLightHover: brand[70], 24 + colorBrandForegroundOnLightPressed: brand[50], 25 + colorBrandForegroundOnLightSelected: brand[60], 26 + colorBrandForegroundInverted: brand[100], 27 + colorBrandForegroundInvertedHover: brand[110], 28 + colorBrandForegroundInvertedPressed: brand[100], 29 + // #endregion 30 + 31 + // #region background 32 + colorBrandBackground: brand[80], 33 + colorBrandBackgroundHover: brand[70], 34 + colorBrandBackgroundPressed: brand[40], 35 + colorBrandBackgroundSelected: brand[60], 36 + colorBrandBackgroundStatic: brand[80], 37 + colorBrandBackground2: brand[160], 38 + colorBrandBackground2Hover: brand[150], 39 + colorBrandBackground2Pressed: brand[130], 40 + colorBrandBackground3Static: brand[60], 41 + colorBrandBackground4Static: brand[40], 42 + colorBrandBackgroundInverted: '#ffffff', 43 + colorBrandBackgroundInvertedHover: brand[160], 44 + colorBrandBackgroundInvertedPressed: brand[140], 45 + colorBrandBackgroundInvertedSelected: brand[150], 46 + // #endregion 47 + 48 + // #region stroke 49 + colorBrandStroke1: brand[80], 50 + colorBrandStroke2: brand[140], 51 + colorBrandStroke2Hover: brand[120], 52 + colorBrandStroke2Pressed: brand[80], 53 + colorBrandStroke2Contrast: brand[140], 54 + // #endregion 55 + 56 + // #region compound brand 57 + colorCompoundBrandForeground1: brand[80], 58 + colorCompoundBrandForeground1Hover: brand[70], 59 + colorCompoundBrandForeground1Pressed: brand[60], 60 + colorCompoundBrandBackground: brand[80], 61 + colorCompoundBrandBackgroundHover: brand[70], 62 + colorCompoundBrandBackgroundPressed: brand[60], 63 + colorCompoundBrandStroke: brand[80], 64 + colorCompoundBrandStrokeHover: brand[70], 65 + colorCompoundBrandStrokePressed: brand[60], 66 + // #endregion 67 + }; 68 + 69 + const dark: Record<string, string> = { 70 + // #region foreground 71 + colorBrandForegroundLink: brand[100], 72 + colorBrandForegroundLinkHover: brand[110], 73 + colorBrandForegroundLinkPressed: brand[90], 74 + colorBrandForegroundLinkSelected: brand[100], 75 + colorBrandForeground1: brand[100], 76 + colorBrandForeground2: brand[110], 77 + colorBrandForeground2Hover: brand[130], 78 + colorBrandForeground2Pressed: brand[160], 79 + colorBrandForegroundOnLight: brand[80], 80 + colorBrandForegroundOnLightHover: brand[70], 81 + colorBrandForegroundOnLightPressed: brand[50], 82 + colorBrandForegroundOnLightSelected: brand[60], 83 + colorBrandForegroundInverted: brand[80], 84 + colorBrandForegroundInvertedHover: brand[70], 85 + colorBrandForegroundInvertedPressed: brand[60], 86 + // #endregion 87 + 88 + // #region background 89 + colorBrandBackground: brand[70], 90 + colorBrandBackgroundHover: brand[80], 91 + colorBrandBackgroundPressed: brand[40], 92 + colorBrandBackgroundSelected: brand[60], 93 + colorBrandBackgroundStatic: brand[80], 94 + colorBrandBackground2: brand[20], 95 + colorBrandBackground2Hover: brand[40], 96 + colorBrandBackground2Pressed: brand[10], 97 + colorBrandBackground3Static: brand[60], 98 + colorBrandBackground4Static: brand[40], 99 + colorBrandBackgroundInverted: '#ffffff', 100 + colorBrandBackgroundInvertedHover: brand[160], 101 + colorBrandBackgroundInvertedPressed: brand[140], 102 + colorBrandBackgroundInvertedSelected: brand[150], 103 + // #endregion 104 + 105 + // #region stroke 106 + colorBrandStroke1: brand[100], 107 + colorBrandStroke2: brand[50], 108 + colorBrandStroke2Hover: brand[50], 109 + colorBrandStroke2Pressed: brand[30], 110 + colorBrandStroke2Contrast: brand[50], 111 + // #endregion 112 + 113 + // #region compound brand 114 + colorCompoundBrandForeground1: brand[100], 115 + colorCompoundBrandForeground1Hover: brand[110], 116 + colorCompoundBrandForeground1Pressed: brand[90], 117 + colorCompoundBrandBackground: brand[100], 118 + colorCompoundBrandBackgroundHover: brand[110], 119 + colorCompoundBrandBackgroundPressed: brand[90], 120 + colorCompoundBrandStroke: brand[100], 121 + colorCompoundBrandStrokeHover: brand[110], 122 + colorCompoundBrandStrokePressed: brand[90], 123 + // #endregion 124 + }; 125 + 126 + return { light, dark }; 127 + }
+45
packages/chrysalis/src/tokens/index.ts
··· 1 + import { generateGreyRamp } from '../grey.js'; 2 + import { generateBrandRamp, generateColorRamp } from '../ramp.js'; 3 + import type { Theme, ThemeConfig } from '../types.js'; 4 + 5 + import { generateBrandTokens } from './brand.js'; 6 + import { generateNeutralTokens } from './neutral.js'; 7 + import { generateStatusTokens } from './status.js'; 8 + 9 + /** default cranberry color for danger status */ 10 + const DEFAULT_DANGER = '#c50f1f'; 11 + /** default green color for success status */ 12 + const DEFAULT_SUCCESS = '#107c10'; 13 + /** default orange color for warning status */ 14 + const DEFAULT_WARNING = '#f7630c'; 15 + 16 + /** 17 + * generates a complete theme from configuration. 18 + * @param config theme configuration with brand and optional status colors 19 + * @returns theme with light and dark token mappings 20 + */ 21 + export function generateTheme(config: ThemeConfig): Theme { 22 + const grey = generateGreyRamp(); 23 + const brand = generateBrandRamp(config.brand); 24 + 25 + const dangerRamp = generateColorRamp(config.danger ?? DEFAULT_DANGER); 26 + const successRamp = generateColorRamp(config.success ?? DEFAULT_SUCCESS); 27 + const warningRamp = generateColorRamp(config.warning ?? DEFAULT_WARNING); 28 + 29 + const neutral = generateNeutralTokens(grey, brand); 30 + const brandTokens = generateBrandTokens(brand); 31 + const status = generateStatusTokens(dangerRamp, successRamp, warningRamp); 32 + 33 + return { 34 + light: { 35 + ...neutral.light, 36 + ...brandTokens.light, 37 + ...status.light, 38 + }, 39 + dark: { 40 + ...neutral.dark, 41 + ...brandTokens.dark, 42 + ...status.dark, 43 + }, 44 + }; 45 + }
+260
packages/chrysalis/src/tokens/neutral.ts
··· 1 + import type { BrandRamp, GreyRamp } from '../types.js'; 2 + 3 + /** 4 + * generates neutral semantic tokens from grey and brand ramps. 5 + * @param grey grey ramp 6 + * @param brand brand ramp 7 + * @returns light and dark neutral token mappings 8 + */ 9 + export function generateNeutralTokens( 10 + grey: GreyRamp, 11 + brand: BrandRamp, 12 + ): { light: Record<string, string>; dark: Record<string, string> } { 13 + const light: Record<string, string> = { 14 + // #region foreground 15 + colorNeutralForeground1: grey[14], 16 + colorNeutralForeground1Hover: grey[14], 17 + colorNeutralForeground1Pressed: grey[14], 18 + colorNeutralForeground1Selected: grey[14], 19 + colorNeutralForeground2: grey[26], 20 + colorNeutralForeground2Hover: grey[14], 21 + colorNeutralForeground2Pressed: grey[14], 22 + colorNeutralForeground2Selected: grey[14], 23 + colorNeutralForeground2BrandHover: brand[80], 24 + colorNeutralForeground2BrandPressed: brand[70], 25 + colorNeutralForeground2BrandSelected: brand[80], 26 + colorNeutralForeground3: grey[38], 27 + colorNeutralForeground3Hover: grey[26], 28 + colorNeutralForeground3Pressed: grey[26], 29 + colorNeutralForeground3Selected: grey[26], 30 + colorNeutralForeground4: grey[44], 31 + colorNeutralForeground5: grey[38], 32 + colorNeutralForeground5Hover: grey[14], 33 + colorNeutralForeground5Pressed: grey[14], 34 + colorNeutralForeground5Selected: grey[14], 35 + colorNeutralForegroundDisabled: grey[74], 36 + colorNeutralForeground1Static: grey[14], 37 + colorNeutralForegroundStaticInverted: '#ffffff', 38 + colorNeutralForegroundInverted: '#ffffff', 39 + colorNeutralForegroundInvertedHover: '#ffffff', 40 + colorNeutralForegroundInvertedPressed: '#ffffff', 41 + colorNeutralForegroundInvertedSelected: '#ffffff', 42 + colorNeutralForegroundInverted2: '#ffffff', 43 + colorNeutralForegroundOnBrand: '#ffffff', 44 + // #endregion 45 + 46 + // #region background 47 + colorNeutralBackground1: '#ffffff', 48 + colorNeutralBackground1Hover: grey[96], 49 + colorNeutralBackground1Pressed: grey[88], 50 + colorNeutralBackground1Selected: grey[92], 51 + colorNeutralBackground2: grey[98], 52 + colorNeutralBackground2Hover: grey[94], 53 + colorNeutralBackground2Pressed: grey[86], 54 + colorNeutralBackground2Selected: grey[90], 55 + colorNeutralBackground3: grey[96], 56 + colorNeutralBackground3Hover: grey[92], 57 + colorNeutralBackground3Pressed: grey[84], 58 + colorNeutralBackground3Selected: grey[88], 59 + colorNeutralBackground4: grey[94], 60 + colorNeutralBackground4Hover: grey[98], 61 + colorNeutralBackground4Pressed: grey[96], 62 + colorNeutralBackground4Selected: '#ffffff', 63 + colorNeutralBackground5: grey[92], 64 + colorNeutralBackground5Hover: grey[96], 65 + colorNeutralBackground5Pressed: grey[94], 66 + colorNeutralBackground5Selected: grey[98], 67 + colorNeutralBackground6: grey[90], 68 + colorNeutralBackgroundAlpha: 'rgba(255, 255, 255, 0.5)', 69 + colorNeutralBackgroundAlpha2: 'rgba(255, 255, 255, 0.8)', 70 + colorNeutralBackgroundDisabled: grey[94], 71 + colorNeutralBackgroundInverted: grey[16], 72 + colorNeutralBackgroundInvertedHover: grey[24], 73 + colorNeutralBackgroundInvertedPressed: grey[12], 74 + colorNeutralBackgroundInvertedSelected: grey[22], 75 + colorNeutralBackgroundStatic: grey[20], 76 + colorSubtleBackground: 'transparent', 77 + colorSubtleBackgroundHover: grey[96], 78 + colorSubtleBackgroundPressed: grey[88], 79 + colorSubtleBackgroundSelected: grey[92], 80 + colorSubtleBackgroundLightAlphaHover: 'rgba(255, 255, 255, 0.7)', 81 + colorSubtleBackgroundLightAlphaPressed: 'rgba(255, 255, 255, 0.5)', 82 + colorSubtleBackgroundLightAlphaSelected: 'transparent', 83 + colorSubtleBackgroundInverted: 'transparent', 84 + colorSubtleBackgroundInvertedHover: 'rgba(0, 0, 0, 0.1)', 85 + colorSubtleBackgroundInvertedPressed: 'rgba(0, 0, 0, 0.3)', 86 + colorSubtleBackgroundInvertedSelected: 'rgba(0, 0, 0, 0.2)', 87 + colorTransparentBackground: 'transparent', 88 + colorTransparentBackgroundHover: 'transparent', 89 + colorTransparentBackgroundPressed: 'transparent', 90 + colorTransparentBackgroundSelected: 'transparent', 91 + // #endregion 92 + 93 + // #region stroke 94 + colorNeutralStroke1: grey[82], 95 + colorNeutralStroke1Hover: grey[78], 96 + colorNeutralStroke1Pressed: grey[70], 97 + colorNeutralStroke1Selected: grey[74], 98 + colorNeutralStroke2: grey[88], 99 + colorNeutralStroke3: grey[94], 100 + colorNeutralStrokeSubtle: grey[88], 101 + colorNeutralStrokeOnBrand: '#ffffff', 102 + colorNeutralStrokeOnBrand2: '#ffffff', 103 + colorNeutralStrokeOnBrand2Hover: '#ffffff', 104 + colorNeutralStrokeOnBrand2Pressed: '#ffffff', 105 + colorNeutralStrokeOnBrand2Selected: '#ffffff', 106 + colorNeutralStrokeDisabled: grey[88], 107 + colorNeutralStrokeInvertedDisabled: 'rgba(255, 255, 255, 0.4)', 108 + colorNeutralStrokeAccessible: grey[38], 109 + colorNeutralStrokeAccessibleHover: grey[34], 110 + colorNeutralStrokeAccessiblePressed: grey[30], 111 + colorNeutralStrokeAccessibleSelected: brand[80], 112 + colorNeutralStrokeAlpha: 'rgba(0, 0, 0, 0.05)', 113 + colorNeutralStrokeAlpha2: 'rgba(255, 255, 255, 0.2)', 114 + colorTransparentStroke: 'transparent', 115 + colorTransparentStrokeInteractive: 'transparent', 116 + colorTransparentStrokeDisabled: 'transparent', 117 + // #endregion 118 + 119 + // #region overlay & focus 120 + colorBackgroundOverlay: 'rgba(0, 0, 0, 0.4)', 121 + colorScrollbarOverlay: 'rgba(0, 0, 0, 0.5)', 122 + colorStrokeFocus1: '#ffffff', 123 + colorStrokeFocus2: '#000000', 124 + // #endregion 125 + 126 + // #region shadow 127 + colorNeutralShadowAmbient: 'rgba(0, 0, 0, 0.12)', 128 + colorNeutralShadowKey: 'rgba(0, 0, 0, 0.14)', 129 + colorNeutralShadowAmbientLighter: 'rgba(0, 0, 0, 0.06)', 130 + colorNeutralShadowKeyLighter: 'rgba(0, 0, 0, 0.07)', 131 + colorNeutralShadowAmbientDarker: 'rgba(0, 0, 0, 0.2)', 132 + colorNeutralShadowKeyDarker: 'rgba(0, 0, 0, 0.24)', 133 + // #endregion 134 + }; 135 + 136 + const dark: Record<string, string> = { 137 + // #region foreground 138 + colorNeutralForeground1: '#ffffff', 139 + colorNeutralForeground1Hover: '#ffffff', 140 + colorNeutralForeground1Pressed: '#ffffff', 141 + colorNeutralForeground1Selected: '#ffffff', 142 + colorNeutralForeground2: grey[84], 143 + colorNeutralForeground2Hover: '#ffffff', 144 + colorNeutralForeground2Pressed: '#ffffff', 145 + colorNeutralForeground2Selected: '#ffffff', 146 + colorNeutralForeground2BrandHover: brand[100], 147 + colorNeutralForeground2BrandPressed: brand[90], 148 + colorNeutralForeground2BrandSelected: brand[100], 149 + colorNeutralForeground3: grey[68], 150 + colorNeutralForeground3Hover: grey[84], 151 + colorNeutralForeground3Pressed: grey[84], 152 + colorNeutralForeground3Selected: grey[84], 153 + colorNeutralForeground4: grey[60], 154 + colorNeutralForeground5: grey[68], 155 + colorNeutralForeground5Hover: '#ffffff', 156 + colorNeutralForeground5Pressed: '#ffffff', 157 + colorNeutralForeground5Selected: '#ffffff', 158 + colorNeutralForegroundDisabled: grey[36], 159 + colorNeutralForeground1Static: grey[14], 160 + colorNeutralForegroundStaticInverted: '#ffffff', 161 + colorNeutralForegroundInverted: grey[14], 162 + colorNeutralForegroundInvertedHover: grey[14], 163 + colorNeutralForegroundInvertedPressed: grey[14], 164 + colorNeutralForegroundInvertedSelected: grey[14], 165 + colorNeutralForegroundInverted2: grey[14], 166 + colorNeutralForegroundOnBrand: '#ffffff', 167 + // #endregion 168 + 169 + // #region background 170 + colorNeutralBackground1: grey[16], 171 + colorNeutralBackground1Hover: grey[24], 172 + colorNeutralBackground1Pressed: grey[12], 173 + colorNeutralBackground1Selected: grey[22], 174 + colorNeutralBackground2: grey[12], 175 + colorNeutralBackground2Hover: grey[20], 176 + colorNeutralBackground2Pressed: grey[8], 177 + colorNeutralBackground2Selected: grey[18], 178 + colorNeutralBackground3: grey[8], 179 + colorNeutralBackground3Hover: grey[16], 180 + colorNeutralBackground3Pressed: grey[4], 181 + colorNeutralBackground3Selected: grey[14], 182 + colorNeutralBackground4: grey[4], 183 + colorNeutralBackground4Hover: grey[12], 184 + colorNeutralBackground4Pressed: '#000000', 185 + colorNeutralBackground4Selected: grey[10], 186 + colorNeutralBackground5: '#000000', 187 + colorNeutralBackground5Hover: grey[8], 188 + colorNeutralBackground5Pressed: grey[2], 189 + colorNeutralBackground5Selected: grey[6], 190 + colorNeutralBackground6: grey[20], 191 + colorNeutralBackgroundAlpha: 'rgba(26, 26, 26, 0.5)', 192 + colorNeutralBackgroundAlpha2: 'rgba(26, 26, 26, 0.7)', 193 + colorNeutralBackgroundDisabled: grey[8], 194 + colorNeutralBackgroundInverted: '#ffffff', 195 + colorNeutralBackgroundInvertedHover: grey[96], 196 + colorNeutralBackgroundInvertedPressed: grey[88], 197 + colorNeutralBackgroundInvertedSelected: grey[92], 198 + colorNeutralBackgroundStatic: grey[24], 199 + colorSubtleBackground: 'transparent', 200 + colorSubtleBackgroundHover: grey[22], 201 + colorSubtleBackgroundPressed: grey[18], 202 + colorSubtleBackgroundSelected: grey[20], 203 + colorSubtleBackgroundLightAlphaHover: 'rgba(255, 255, 255, 0.1)', 204 + colorSubtleBackgroundLightAlphaPressed: 'rgba(255, 255, 255, 0.05)', 205 + colorSubtleBackgroundLightAlphaSelected: 'transparent', 206 + colorSubtleBackgroundInverted: 'transparent', 207 + colorSubtleBackgroundInvertedHover: 'rgba(0, 0, 0, 0.1)', 208 + colorSubtleBackgroundInvertedPressed: 'rgba(0, 0, 0, 0.3)', 209 + colorSubtleBackgroundInvertedSelected: 'rgba(0, 0, 0, 0.2)', 210 + colorTransparentBackground: 'transparent', 211 + colorTransparentBackgroundHover: 'transparent', 212 + colorTransparentBackgroundPressed: 'transparent', 213 + colorTransparentBackgroundSelected: 'transparent', 214 + // #endregion 215 + 216 + // #region stroke 217 + colorNeutralStroke1: grey[40], 218 + colorNeutralStroke1Hover: grey[46], 219 + colorNeutralStroke1Pressed: grey[42], 220 + colorNeutralStroke1Selected: grey[44], 221 + colorNeutralStroke2: grey[32], 222 + colorNeutralStroke3: grey[24], 223 + colorNeutralStrokeSubtle: grey[4], 224 + colorNeutralStrokeOnBrand: grey[16], 225 + colorNeutralStrokeOnBrand2: '#ffffff', 226 + colorNeutralStrokeOnBrand2Hover: '#ffffff', 227 + colorNeutralStrokeOnBrand2Pressed: '#ffffff', 228 + colorNeutralStrokeOnBrand2Selected: '#ffffff', 229 + colorNeutralStrokeDisabled: grey[26], 230 + colorNeutralStrokeInvertedDisabled: 'rgba(255, 255, 255, 0.4)', 231 + colorNeutralStrokeAccessible: grey[68], 232 + colorNeutralStrokeAccessibleHover: grey[74], 233 + colorNeutralStrokeAccessiblePressed: grey[70], 234 + colorNeutralStrokeAccessibleSelected: brand[100], 235 + colorNeutralStrokeAlpha: 'rgba(255, 255, 255, 0.1)', 236 + colorNeutralStrokeAlpha2: 'rgba(255, 255, 255, 0.2)', 237 + colorTransparentStroke: 'transparent', 238 + colorTransparentStrokeInteractive: 'transparent', 239 + colorTransparentStrokeDisabled: 'transparent', 240 + // #endregion 241 + 242 + // #region overlay & focus 243 + colorBackgroundOverlay: 'rgba(0, 0, 0, 0.5)', 244 + colorScrollbarOverlay: 'rgba(255, 255, 255, 0.6)', 245 + colorStrokeFocus1: '#000000', 246 + colorStrokeFocus2: '#ffffff', 247 + // #endregion 248 + 249 + // #region shadow 250 + colorNeutralShadowAmbient: 'rgba(0, 0, 0, 0.24)', 251 + colorNeutralShadowKey: 'rgba(0, 0, 0, 0.28)', 252 + colorNeutralShadowAmbientLighter: 'rgba(0, 0, 0, 0.12)', 253 + colorNeutralShadowKeyLighter: 'rgba(0, 0, 0, 0.14)', 254 + colorNeutralShadowAmbientDarker: 'rgba(0, 0, 0, 0.4)', 255 + colorNeutralShadowKeyDarker: 'rgba(0, 0, 0, 0.48)', 256 + // #endregion 257 + }; 258 + 259 + return { light, dark }; 260 + }
+102
packages/chrysalis/src/tokens/status.ts
··· 1 + import type { ColorRamp } from '../types.js'; 2 + 3 + /** 4 + * generates status semantic tokens from color ramps. 5 + * @param danger danger color ramp (cranberry) 6 + * @param success success color ramp (green) 7 + * @param warning warning color ramp (orange) 8 + * @returns light and dark status token mappings 9 + */ 10 + export function generateStatusTokens( 11 + danger: ColorRamp, 12 + success: ColorRamp, 13 + warning: ColorRamp, 14 + ): { light: Record<string, string>; dark: Record<string, string> } { 15 + const light: Record<string, string> = { 16 + // #region success (green) 17 + colorStatusSuccessBackground1: success.tint60, 18 + colorStatusSuccessBackground2: success.tint40, 19 + colorStatusSuccessBackground3: success.primary, 20 + colorStatusSuccessForeground1: success.shade30, 21 + colorStatusSuccessForeground2: success.shade10, 22 + colorStatusSuccessForeground3: success.tint20, 23 + colorStatusSuccessForegroundInverted: success.tint30, 24 + colorStatusSuccessBorderActive: success.primary, 25 + colorStatusSuccessBorder1: success.tint40, 26 + colorStatusSuccessBorder2: success.tint20, 27 + // #endregion 28 + 29 + // #region warning (orange) 30 + colorStatusWarningBackground1: warning.tint60, 31 + colorStatusWarningBackground2: warning.tint40, 32 + colorStatusWarningBackground3: warning.primary, 33 + colorStatusWarningForeground1: warning.shade30, 34 + colorStatusWarningForeground2: warning.shade10, 35 + colorStatusWarningForeground3: warning.shade30, 36 + colorStatusWarningForegroundInverted: warning.tint30, 37 + colorStatusWarningBorderActive: warning.primary, 38 + colorStatusWarningBorder1: warning.tint40, 39 + colorStatusWarningBorder2: warning.shade30, 40 + // #endregion 41 + 42 + // #region danger (cranberry) 43 + colorStatusDangerBackground1: danger.tint60, 44 + colorStatusDangerBackground2: danger.tint40, 45 + colorStatusDangerBackground3: danger.primary, 46 + colorStatusDangerBackground3Hover: danger.shade10, 47 + colorStatusDangerBackground3Pressed: danger.shade20, 48 + colorStatusDangerForeground1: danger.shade30, 49 + colorStatusDangerForeground2: danger.shade10, 50 + colorStatusDangerForeground3: danger.primary, 51 + colorStatusDangerForegroundInverted: danger.tint30, 52 + colorStatusDangerBorderActive: danger.primary, 53 + colorStatusDangerBorder1: danger.tint40, 54 + colorStatusDangerBorder2: danger.primary, 55 + // #endregion 56 + }; 57 + 58 + const dark: Record<string, string> = { 59 + // #region success (green) 60 + colorStatusSuccessBackground1: success.shade40, 61 + colorStatusSuccessBackground2: success.shade30, 62 + colorStatusSuccessBackground3: success.primary, 63 + colorStatusSuccessForeground1: success.tint30, 64 + colorStatusSuccessForeground2: success.tint40, 65 + colorStatusSuccessForeground3: success.tint20, 66 + colorStatusSuccessForegroundInverted: success.shade10, 67 + colorStatusSuccessBorderActive: success.tint30, 68 + colorStatusSuccessBorder1: success.primary, 69 + colorStatusSuccessBorder2: success.tint20, 70 + // #endregion 71 + 72 + // #region warning (orange) 73 + colorStatusWarningBackground1: warning.shade40, 74 + colorStatusWarningBackground2: warning.shade30, 75 + colorStatusWarningBackground3: warning.primary, 76 + colorStatusWarningForeground1: warning.tint30, 77 + colorStatusWarningForeground2: warning.tint40, 78 + colorStatusWarningForeground3: warning.tint40, 79 + colorStatusWarningForegroundInverted: warning.shade30, 80 + colorStatusWarningBorderActive: warning.tint30, 81 + colorStatusWarningBorder1: warning.primary, 82 + colorStatusWarningBorder2: warning.tint20, 83 + // #endregion 84 + 85 + // #region danger (cranberry) 86 + colorStatusDangerBackground1: danger.shade40, 87 + colorStatusDangerBackground2: danger.shade30, 88 + colorStatusDangerBackground3: danger.primary, 89 + colorStatusDangerBackground3Hover: danger.shade10, 90 + colorStatusDangerBackground3Pressed: danger.shade20, 91 + colorStatusDangerForeground1: danger.tint30, 92 + colorStatusDangerForeground2: danger.tint40, 93 + colorStatusDangerForeground3: danger.tint40, 94 + colorStatusDangerForegroundInverted: danger.tint10, 95 + colorStatusDangerBorderActive: danger.tint30, 96 + colorStatusDangerBorder1: danger.primary, 97 + colorStatusDangerBorder2: danger.tint30, 98 + // #endregion 99 + }; 100 + 101 + return { light, dark }; 102 + }
+55
packages/chrysalis/src/types.ts
··· 1 + /** 2 + * color ramp with 12 stops for brand and status colors. 3 + * shades (darker) and tints (lighter) are generated from the primary color. 4 + */ 5 + export interface ColorRamp { 6 + /** darkest shade */ 7 + shade50: string; 8 + shade40: string; 9 + shade30: string; 10 + shade20: string; 11 + shade10: string; 12 + /** base color */ 13 + primary: string; 14 + tint10: string; 15 + tint20: string; 16 + tint30: string; 17 + tint40: string; 18 + tint50: string; 19 + /** lightest tint */ 20 + tint60: string; 21 + } 22 + 23 + /** 24 + * grey ramp with 49 stops (lightness 2-98% in steps of 2). 25 + * keys are lightness percentages. 26 + */ 27 + export type GreyRamp = Record<number, string>; 28 + 29 + /** 30 + * brand ramp with 16 stops (10-160 in steps of 10). 31 + * keys are position values. 32 + */ 33 + export type BrandRamp = Record<number, string>; 34 + 35 + /** 36 + * theme configuration for generating color tokens. 37 + */ 38 + export interface ThemeConfig { 39 + /** brand base color (hex) */ 40 + brand: string; 41 + /** danger color, defaults to cranberry (#c50f1f) */ 42 + danger?: string; 43 + /** success color, defaults to green (#107c10) */ 44 + success?: string; 45 + /** warning color, defaults to orange (#f7630c) */ 46 + warning?: string; 47 + } 48 + 49 + /** 50 + * generated theme containing light and dark token mappings. 51 + */ 52 + export interface Theme { 53 + light: Record<string, string>; 54 + dark: Record<string, string>; 55 + }
+24
packages/chrysalis/tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "outDir": "dist/", 4 + "types": ["bun"], 5 + "esModuleInterop": true, 6 + "skipLibCheck": true, 7 + "target": "ESNext", 8 + "allowJs": true, 9 + "resolveJsonModule": true, 10 + "moduleDetection": "force", 11 + "isolatedModules": true, 12 + "verbatimModuleSyntax": true, 13 + "strict": true, 14 + "noImplicitOverride": true, 15 + "noUnusedLocals": true, 16 + "noUnusedParameters": true, 17 + "noFallthroughCasesInSwitch": true, 18 + "module": "NodeNext", 19 + "sourceMap": true, 20 + "declaration": true, 21 + "declarationMap": true 22 + }, 23 + "include": ["src"] 24 + }