Generate deterministic color schemes based on atproto DIDs.
did-colors.ts
edited
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}