diff --git a/src/lib/classColors.test.ts b/src/lib/classColors.test.ts
new file mode 100644
index 0000000..04b8cf8
--- /dev/null
+++ b/src/lib/classColors.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from 'vitest';
+import { pickDistinctClassColor } from './classColors';
+
+describe('classColors', () => {
+ it('picks a palette color that is not already used', () => {
+ const color = pickDistinctClassColor(['#ef4444', '#f97316'], () => 0);
+ expect(color).toBe('#f59e0b');
+ });
+
+ it('falls back to generated colors when the palette is exhausted', () => {
+ const usedColors = [
+ '#ef4444',
+ '#f97316',
+ '#f59e0b',
+ '#eab308',
+ '#84cc16',
+ '#22c55e',
+ '#10b981',
+ '#14b8a6',
+ '#06b6d4',
+ '#0ea5e9',
+ '#3b82f6',
+ '#6366f1',
+ '#8b5cf6',
+ '#a855f7',
+ '#d946ef',
+ '#ec4899',
+ '#f43f5e',
+ ];
+ const color = pickDistinctClassColor(usedColors, () => 0.25);
+ expect(color).toMatch(/^#[0-9a-f]{6}$/);
+ expect(usedColors).not.toContain(color);
+ });
+});
diff --git a/src/lib/classColors.ts b/src/lib/classColors.ts
new file mode 100644
index 0000000..05495d7
--- /dev/null
+++ b/src/lib/classColors.ts
@@ -0,0 +1,78 @@
+const CLASS_COLOR_PALETTE = [
+ '#ef4444',
+ '#f97316',
+ '#f59e0b',
+ '#eab308',
+ '#84cc16',
+ '#22c55e',
+ '#10b981',
+ '#14b8a6',
+ '#06b6d4',
+ '#0ea5e9',
+ '#3b82f6',
+ '#6366f1',
+ '#8b5cf6',
+ '#a855f7',
+ '#d946ef',
+ '#ec4899',
+ '#f43f5e',
+];
+
+function normalizeHexColor(color: string): string | null {
+ const raw = color.trim().toLowerCase();
+ if (/^#[0-9a-f]{6}$/.test(raw)) return raw;
+ if (/^#[0-9a-f]{3}$/.test(raw)) {
+ return `#${raw[1]}${raw[1]}${raw[2]}${raw[2]}${raw[3]}${raw[3]}`;
+ }
+ return null;
+}
+
+function hslToHex(hue: number, saturation: number, lightness: number): string {
+ const normalizedSaturation = saturation / 100;
+ const normalizedLightness = lightness / 100;
+ const chroma = (1 - Math.abs(2 * normalizedLightness - 1)) * normalizedSaturation;
+ const intermediate = chroma * (1 - Math.abs(((hue / 60) % 2) - 1));
+ const match = normalizedLightness - chroma / 2;
+ const [red, green, blue] = hue < 60
+ ? [chroma, intermediate, 0]
+ : hue < 120
+ ? [intermediate, chroma, 0]
+ : hue < 180
+ ? [0, chroma, intermediate]
+ : hue < 240
+ ? [0, intermediate, chroma]
+ : hue < 300
+ ? [intermediate, 0, chroma]
+ : [chroma, 0, intermediate];
+ return [red, green, blue]
+ .map((part) => Math.round((part + match) * 255).toString(16).padStart(2, '0'))
+ .join('')
+ .replace(/^/, '#');
+}
+
+export function pickDistinctClassColor(
+ existingColors: Array
,
+ random: () => number = Math.random,
+): string {
+ const usedColors = new Set(
+ existingColors
+ .map((color) => normalizeHexColor(color || ''))
+ .filter((color): color is string => Boolean(color)),
+ );
+ const paletteCandidates = CLASS_COLOR_PALETTE.filter((color) => !usedColors.has(color));
+ if (paletteCandidates.length > 0) {
+ return paletteCandidates[Math.floor(random() * paletteCandidates.length) % paletteCandidates.length];
+ }
+
+ for (let attempt = 0; attempt < 48; attempt += 1) {
+ const color = hslToHex(Math.floor(random() * 360), 76, 56);
+ if (!usedColors.has(color)) return color;
+ }
+
+ let fallbackIndex = 0;
+ while (true) {
+ const color = hslToHex((fallbackIndex * 137) % 360, 76, 56);
+ if (!usedColors.has(color)) return color;
+ fallbackIndex += 1;
+ }
+}