docs(docs): add unique tag colours and pill‑styled badges for problem tags.

This commit is contained in:
Prad Nukala committed 2026-09-30 15:17:31 -04:00
1 parent a7737502e1
commit a8bd4fbcd7
2 files changed
+125 -8

No files matched your search

+80
View File
@@ -0,0 +1,80 @@
/**
* One colour per topic tag, unique across the site: the problem-page badge
* row (`components/ProblemBadges.astro`) paints each tag with it, so a tag
* reads the same on every page and no two tags share a colour.
*
* Every colour sits at one OKLCH lightness, so each badge has the same
* contrast whatever its hue; only the hue varies. A tag's hue is seeded by a
* hash of its slug (FNV-1a) and walked forward past any hue already taken
* within `gap` degrees, in slug order. Seeding by slug rather than by position
* keeps colours stable as tags come and go: a new tag moves only itself,
* unless it lands on a hue a later slug's seed wanted.
*
* Blume's Badge takes a custom colour only as hex, and uses that one value
* for both themes, so the lightness is the midpoint that stays legible as text
* on the light and the dark background alike.
*/
const LIGHTNESS = 0.6;
const CHROMA = 0.15;
/** slug → `#rrggbb`, pairwise distinct, for every slug in `slugs`. */
export function tagColors(slugs: Iterable<string>): Map<string, string> {
const sorted = [...new Set(slugs)].sort();
// Each taken hue blocks 2·gap−1 whole degrees, so n·(2·gap−1) < 360 always
// leaves a free one; past 180 tags the hues can only be distinct.
const gap = Math.max(1, Math.floor(360 / (2 * sorted.length)));
const taken: number[] = [];
const colors = new Map<string, string>();
const used = new Set<string>();
for (const slug of sorted) {
let hue = fnv1a(slug) % 360;
for (let step = 0; step < 360; step++, hue = (hue + 1) % 360) {
if (taken.some((t) => Math.min(Math.abs(t - hue), 360 - Math.abs(t - hue)) < gap)) continue;
if (!used.has(oklchHex(hue))) break;
}
taken.push(hue);
const hex = oklchHex(hue);
used.add(hex);
colors.set(slug, hex);
}
return colors;
}
function fnv1a(text: string): number {
let hash = 0x811c9dc5;
for (let i = 0; i < text.length; i++) {
hash ^= text.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
}
/**
* `oklch(LIGHTNESS CHROMA hue)` as sRGB hex. Hues sRGB cannot reach at full
* chroma (cyans) give up chroma until they fit, rather than being clipped
* per channel, which would shift the hue.
*/
function oklchHex(hue: number): string {
const rad = (hue * Math.PI) / 180;
for (let chroma = CHROMA; ; chroma -= 0.005) {
const a = chroma * Math.cos(rad);
const b = chroma * Math.sin(rad);
const l = (LIGHTNESS + 0.3963377774 * a + 0.2158037573 * b) ** 3;
const m = (LIGHTNESS - 0.1055613458 * a - 0.0638541728 * b) ** 3;
const s = (LIGHTNESS - 0.0894841775 * a - 1.291485548 * b) ** 3;
const rgb = [
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * l - 0.7034186147 * m + 1.707614681 * s,
];
if (chroma > 0 && rgb.some((c) => c < 0 || c > 1)) continue;
return `#${rgb
.map((c) => {
const v = Math.min(1, Math.max(0, c));
const srgb = v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055;
return Math.round(srgb * 255).toString(16).padStart(2, "0");
})
.join("")}`;
}
}