Generate deterministic color schemes based on atproto DIDs.
did-colors.ts edited
199 lines 5.3 kB view raw
1// The DTCG schema makes the token output consumable by design-token tooling. 2const DTCG_SCHEMA_URL = 3 "https://www.designtokens.org/schemas/2025.10/format.json"; 4 5/** CSS-ready semantic colors for a dark profile theme. */ 6export type DidColorScheme = { 7 background: string; 8 card: string; 9 border: string; 10 accent: string; 11}; 12 13/** A minimal DTCG color-token document for platform-specific exporters. */ 14export type DidDesignTokens = { 15 $schema: string; 16 color: { 17 canvas: DidColorToken; 18 surface: DidColorToken; 19 divider: DidColorToken; 20 accent: DidColorToken; 21 }; 22}; 23 24type DidColorToken = { 25 $type: "color"; 26 $value: { 27 colorSpace: "srgb"; 28 components: [number, number, number]; 29 hex: string; 30 }; 31}; 32 33type HslColor = { 34 hue: number; 35 saturation: number; 36 lightness: number; 37}; 38 39type GeneratedColors = { 40 canvas: HslColor; 41 surface: HslColor; 42 divider: HslColor; 43 accent: HslColor; 44}; 45 46export type DidTheme = { 47 css: DidColorScheme; 48 tokens: DidDesignTokens; 49}; 50 51/** 52 * Creates a stable dark-pastel theme from an AT Protocol DID. 53 * 54 * The DID seeds the generator, so the output is varied across identities but 55 * remains stable across requests. Consumers can use `css` directly or pass 56 * `tokens` to a DTCG-compatible exporter. 57 */ 58export function getDidTheme(did: string): DidTheme { 59 const colors = generateColors(did); 60 const css = { 61 background: formatHsl(colors.canvas), 62 card: formatHsl(colors.surface), 63 border: formatHsl(colors.divider), 64 accent: formatHsl(colors.accent), 65 }; 66 67 return { 68 css, 69 tokens: { 70 $schema: DTCG_SCHEMA_URL, 71 color: { 72 canvas: createColorToken(colors.canvas), 73 surface: createColorToken(colors.surface), 74 divider: createColorToken(colors.divider), 75 accent: createColorToken(colors.accent), 76 }, 77 }, 78 }; 79} 80 81/** Returns only the CSS colors for consumers that do not need token metadata. */ 82export function getDidColorScheme(did: string): DidColorScheme { 83 return getDidTheme(did).css; 84} 85 86function generateColors(did: string): GeneratedColors { 87 // Keep every random value inside a hand-tuned range so arbitrary hues stay 88 // readable as dark backgrounds and pleasant as pastel accents. 89 const random = createSeededRandom(hashString(did)); 90 const hue = random() * 360; 91 const backgroundSaturation = 20 + random() * 12; 92 const backgroundLightness = 11 + random() * 6; 93 const cardSaturation = backgroundSaturation + random() * 4; 94 const cardLightness = backgroundLightness + 4 + random() * 3; 95 96 return { 97 canvas: { 98 hue, 99 saturation: backgroundSaturation, 100 lightness: backgroundLightness, 101 }, 102 surface: { 103 hue, 104 saturation: cardSaturation, 105 lightness: cardLightness, 106 }, 107 divider: { 108 hue, 109 saturation: 35 + random() * 20, 110 lightness: 35 + random() * 12, 111 }, 112 accent: { 113 hue, 114 saturation: 65 + random() * 20, 115 lightness: 76 + random() * 10, 116 }, 117 }; 118} 119 120function createColorToken(color: HslColor): DidColorToken { 121 const components = hslToRgb(color); 122 123 return { 124 $type: "color", 125 $value: { 126 colorSpace: "srgb", 127 components, 128 hex: rgbToHex(components), 129 }, 130 }; 131} 132 133function formatHsl(color: HslColor): string { 134 return `hsl(${Math.round(color.hue)} ${Math.round(color.saturation)}% ${Math.round(color.lightness)}%)`; 135} 136 137function hslToRgb(color: HslColor): [number, number, number] { 138 // DTCG tokens use normalized sRGB components, while the browser theme uses 139 // HSL because it makes the generated lightness and saturation easy to tune. 140 const saturation = color.saturation / 100; 141 const lightness = color.lightness / 100; 142 const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation; 143 const huePart = color.hue / 60; 144 const secondComponent = chroma * (1 - Math.abs((huePart % 2) - 1)); 145 const lightnessAdjustment = lightness - chroma / 2; 146 const rgbByHue = getRgbByHue(huePart, chroma, secondComponent); 147 148 return rgbByHue.map((component) => component + lightnessAdjustment) as [ 149 number, 150 number, 151 number, 152 ]; 153} 154 155function getRgbByHue( 156 huePart: number, 157 chroma: number, 158 secondComponent: number, 159): [number, number, number] { 160 if (huePart < 1) return [chroma, secondComponent, 0]; 161 if (huePart < 2) return [secondComponent, chroma, 0]; 162 if (huePart < 3) return [0, chroma, secondComponent]; 163 if (huePart < 4) return [0, secondComponent, chroma]; 164 if (huePart < 5) return [secondComponent, 0, chroma]; 165 166 return [chroma, 0, secondComponent]; 167} 168 169function rgbToHex(components: [number, number, number]): string { 170 return `#${components 171 .map((component) => 172 Math.round(component * 255) 173 .toString(16) 174 .padStart(2, "0"), 175 ) 176 .join("")}`; 177} 178 179function hashString(value: string): number { 180 let hash = 0; 181 182 for (const character of value) { 183 const codePoint = character.codePointAt(0) ?? 0; 184 hash = (hash * 31 + codePoint) >>> 0; 185 } 186 187 return hash; 188} 189 190function createSeededRandom(seed: number): () => number { 191 // This is deterministic pseudo-randomness, not cryptographic randomness. 192 // It is used only to distribute identities across the color space. 193 let state = seed || 1; 194 195 return () => { 196 state = (Math.imul(1664525, state) + 1013904223) >>> 0; 197 return state / 2 ** 32; 198 }; 199}