diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9dafb05d..8679d8be 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -52,5 +52,8 @@ jobs:
- name: Path / task-ref guard tests
run: npm run test:paths
+ - name: Color contrast helper tests
+ run: npm run test:color
+
- name: Build
run: npm run build
diff --git a/package.json b/package.json
index 87f5bc1e..6b3a61ec 100644
--- a/package.json
+++ b/package.json
@@ -10,6 +10,7 @@
"lint": "vue-cli-service lint",
"test:grapheme": "node --experimental-strip-types scripts/test-grapheme.mjs",
"test:paths": "node --experimental-strip-types scripts/test-paths.mjs",
+ "test:color": "node --experimental-strip-types scripts/test-color.mjs",
"test:class-listeners": "node --experimental-strip-types scripts/test-class-listeners.mjs",
"test:me-board": "node --experimental-strip-types scripts/test-me-board.mjs",
"test:acting-as": "node --experimental-strip-types scripts/test-acting-as.mjs",
diff --git a/scripts/test-color.mjs b/scripts/test-color.mjs
new file mode 100644
index 00000000..503824e0
--- /dev/null
+++ b/scripts/test-color.mjs
@@ -0,0 +1,168 @@
+/**
+ * Unit tests for class chip color mix / contrast helpers.
+ * Run: node --experimental-strip-types scripts/test-color.mjs
+ * (imports ../src/common/color.ts)
+ */
+import {
+ parseHex,
+ rgbToHex,
+ mixRgb,
+ mixClassChipBackground,
+ readableTextOnBackground,
+ contrastRatio,
+ relativeLuminance,
+ classChipColors,
+ resolvePageBg,
+ PAGE_BG_LIGHT,
+ PAGE_BG_DARK,
+ CLASS_CHIP_MIX_ALPHA,
+ CONTRAST_TARGET,
+} from "../src/common/color.ts";
+
+let failed = 0;
+
+function assert(cond, msg) {
+ if (!cond) {
+ failed++;
+ console.error("FAIL:", msg);
+ } else {
+ console.log("ok:", msg);
+ }
+}
+
+function assertEq(actual, expected, label) {
+ const ok = actual === expected;
+ if (!ok) {
+ failed++;
+ console.error(
+ `FAIL: ${label}\n expected: ${JSON.stringify(expected)}\n actual: ${JSON.stringify(actual)}`,
+ );
+ } else {
+ console.log(`ok: ${label} → ${JSON.stringify(actual)}`);
+ }
+}
+
+function assertClose(actual, expected, eps, label) {
+ const ok = Math.abs(actual - expected) <= eps;
+ if (!ok) {
+ failed++;
+ console.error(`FAIL: ${label}\n expected ≈ ${expected} (±${eps})\n actual: ${actual}`);
+ } else {
+ console.log(`ok: ${label} → ${actual}`);
+ }
+}
+
+console.log("--- color helper tests ---\n");
+
+// parseHex
+{
+ assertEq(rgbToHex(parseHex("#abc")), "#aabbcc", "parseHex #abc");
+ assertEq(rgbToHex(parseHex("45818e")), "#45818e", "parseHex without #");
+ assertEq(rgbToHex(parseHex("#45818e2d")), "#45818e", "parseHex ignores alpha suffix");
+ assert(parseHex("nope") === null, "parseHex rejects garbage");
+}
+
+// resolvePageBg
+{
+ assertEq(resolvePageBg("light"), PAGE_BG_LIGHT, "resolvePageBg light");
+ assertEq(resolvePageBg("dark"), PAGE_BG_DARK, "resolvePageBg dark");
+ assertEq(resolvePageBg(false), PAGE_BG_LIGHT, "resolvePageBg false → light");
+ assertEq(resolvePageBg(true), PAGE_BG_DARK, "resolvePageBg true → dark");
+ assertEq(resolvePageBg("#1c1c1c"), "#1c1c1c", "resolvePageBg hex passthrough");
+}
+
+// mix: translucent 2d over white ≈ legacy visual, but opaque
+{
+ const classRgb = parseHex("#45818e");
+ const white = parseHex(PAGE_BG_LIGHT);
+ const mixed = mixRgb(classRgb, white, CLASS_CHIP_MIX_ALPHA);
+ const expectedR = Math.round(0x45 * CLASS_CHIP_MIX_ALPHA + 255 * (1 - CLASS_CHIP_MIX_ALPHA));
+ const expectedG = Math.round(0x81 * CLASS_CHIP_MIX_ALPHA + 255 * (1 - CLASS_CHIP_MIX_ALPHA));
+ const expectedB = Math.round(0x8e * CLASS_CHIP_MIX_ALPHA + 255 * (1 - CLASS_CHIP_MIX_ALPHA));
+ assertEq(mixed.r, expectedR, "mixRgb R against white");
+ assertEq(mixed.g, expectedG, "mixRgb G against white");
+ assertEq(mixed.b, expectedB, "mixRgb B against white");
+
+ const hex = mixClassChipBackground("#45818e", "light");
+ assertEq(hex, rgbToHex(mixed), "mixClassChipBackground light matches mixRgb");
+ assert(!hex.endsWith("2d"), "mixed bg is opaque hex (no 2d alpha suffix)");
+ assert(hex.length === 7, "mixed bg is #rrggbb");
+}
+
+// dark page bg mix differs from light
+{
+ const lightBg = mixClassChipBackground("#ff0000", "light");
+ const darkBg = mixClassChipBackground("#ff0000", "dark");
+ assert(lightBg !== darkBg, "light vs dark page bg produce different mixes");
+ assertEq(
+ mixClassChipBackground("#ff0000", PAGE_BG_DARK),
+ darkBg,
+ "explicit dark hex matches dark flag",
+ );
+}
+
+// luminance / contrast basics
+{
+ const black = { r: 0, g: 0, b: 0 };
+ const white = { r: 255, g: 255, b: 255 };
+ assertClose(relativeLuminance(black), 0, 1e-6, "luminance black");
+ assertClose(relativeLuminance(white), 1, 1e-6, "luminance white");
+ assertClose(contrastRatio(black, white), 21, 1e-6, "contrast black/white = 21");
+ assertClose(contrastRatio(white, white), 1, 1e-6, "contrast white/white = 1");
+}
+
+// readable text: high-contrast class color kept as-is on light mixed chip
+{
+ const bg = mixClassChipBackground("#0000aa", "light");
+ const fg = readableTextOnBackground("#0000aa", bg);
+ assertEq(fg, "#0000aa", "keeps class color when contrast is already strong");
+ assert(
+ contrastRatio(parseHex(fg), parseHex(bg)) >= CONTRAST_TARGET,
+ `fg/bg contrast ≥ ${CONTRAST_TARGET}`,
+ );
+}
+
+// readable text: pale yellow on light mix needs darkening
+{
+ const pale = "#f5e6a3";
+ const bg = mixClassChipBackground(pale, "light");
+ const fg = readableTextOnBackground(pale, bg);
+ const ratio = contrastRatio(parseHex(fg), parseHex(bg));
+ assert(fg !== pale || ratio >= CONTRAST_TARGET, "pale color adjusted or already ok");
+ assert(
+ ratio >= CONTRAST_TARGET || ratio > contrastRatio(parseHex(pale), parseHex(bg)),
+ `adjusted fg improves contrast (got ${ratio.toFixed(2)})`,
+ );
+ // Prefer meeting AA when possible against near-white chip bg
+ assert(ratio >= CONTRAST_TARGET, `pale-on-light reaches ~${CONTRAST_TARGET}:1 (got ${ratio.toFixed(2)})`);
+}
+
+// dark theme: light class color on dark mixed bg
+{
+ const bright = "#a8e6ff";
+ const bg = mixClassChipBackground(bright, "dark");
+ const fg = readableTextOnBackground(bright, bg);
+ const ratio = contrastRatio(parseHex(fg), parseHex(bg));
+ assert(ratio >= CONTRAST_TARGET, `bright-on-dark reaches ~${CONTRAST_TARGET}:1 (got ${ratio.toFixed(2)})`);
+}
+
+// classChipColors convenience
+{
+ const { background, foreground, classColor } = classChipColors("#45818e", "light");
+ assertEq(classColor, "#45818e", "classChipColors preserves classColor");
+ assertEq(background, mixClassChipBackground("#45818e", "light"), "classChipColors bg");
+ assertEq(foreground, readableTextOnBackground("#45818e", background), "classChipColors fg");
+}
+
+// invalid class color: still returns a usable opaque bg from page
+{
+ const bg = mixClassChipBackground("not-a-color", "light");
+ assertEq(bg, PAGE_BG_LIGHT, "invalid class color → page bg");
+}
+
+console.log("\n--- done ---");
+if (failed) {
+ console.error(`\n${failed} failure(s)`);
+ process.exit(1);
+}
+console.log("\nall passed");
diff --git a/src/common/color.ts b/src/common/color.ts
new file mode 100644
index 00000000..225324f3
--- /dev/null
+++ b/src/common/color.ts
@@ -0,0 +1,245 @@
+/**
+ * Pure color helpers for class-name chips.
+ * Mixes class accent color against flat page `--color-bg` (opaque) and picks
+ * a readable foreground. No Vue/DOM required — safe for Node unit tests.
+ * @file common/color.ts
+ */
+
+/** Flat page backgrounds matching `base.css` light / dark `--color-bg`. */
+export const PAGE_BG_LIGHT = "#ffffff";
+export const PAGE_BG_DARK = "#1c1c1c";
+
+/**
+ * Alpha previously encoded as hex suffix `2d` (`color + '2d'`).
+ * Used when mixing class color over page bg so chips stay visually close.
+ */
+export const CLASS_CHIP_MIX_ALPHA = 0x2d / 255;
+
+/** WCAG AA normal-text contrast target. */
+export const CONTRAST_TARGET = 4.5;
+
+export type ThemeBgFlag = "light" | "dark" | boolean;
+
+export interface Rgb {
+ r: number;
+ g: number;
+ b: number;
+}
+
+/** Clamp to 0–255 integer channel. */
+function clampByte(n: number): number {
+ return Math.max(0, Math.min(255, Math.round(n)));
+}
+
+/**
+ * Parse `#rgb`, `#rrggbb`, or `#rrggbbaa` (alpha ignored) into RGB.
+ * Returns null if the string is not a usable hex color.
+ */
+export function parseHex(hex: string): Rgb | null {
+ if (hex == null) return null;
+ let s = String(hex).trim();
+ if (s.startsWith("#")) s = s.slice(1);
+ if (/^[0-9a-fA-F]{3}$/.test(s)) {
+ return {
+ r: parseInt(s[0] + s[0], 16),
+ g: parseInt(s[1] + s[1], 16),
+ b: parseInt(s[2] + s[2], 16),
+ };
+ }
+ if (/^[0-9a-fA-F]{6}$/.test(s) || /^[0-9a-fA-F]{8}$/.test(s)) {
+ return {
+ r: parseInt(s.slice(0, 2), 16),
+ g: parseInt(s.slice(2, 4), 16),
+ b: parseInt(s.slice(4, 6), 16),
+ };
+ }
+ return null;
+}
+
+/** Format RGB as lowercase `#rrggbb`. */
+export function rgbToHex({ r, g, b }: Rgb): string {
+ const h = (n: number) => clampByte(n).toString(16).padStart(2, "0");
+ return `#${h(r)}${h(g)}${h(b)}`;
+}
+
+/**
+ * Resolve page background hex from an explicit hex, or a light/dark flag.
+ * `true` / `"dark"` → dark; `false` / `"light"` → light.
+ */
+export function resolvePageBg(bgOrFlag: string | ThemeBgFlag): string {
+ if (typeof bgOrFlag === "boolean") {
+ return bgOrFlag ? PAGE_BG_DARK : PAGE_BG_LIGHT;
+ }
+ const flag = String(bgOrFlag).trim().toLowerCase();
+ if (flag === "dark") return PAGE_BG_DARK;
+ if (flag === "light") return PAGE_BG_LIGHT;
+ const parsed = parseHex(flag.startsWith("#") ? flag : `#${flag}`);
+ return parsed ? rgbToHex(parsed) : PAGE_BG_LIGHT;
+}
+
+/**
+ * Mix `foreground` over `background` with opacity `alpha` (0–1).
+ * Result is opaque RGB (same visual as translucent fg on flat bg).
+ */
+export function mixRgb(foreground: Rgb, background: Rgb, alpha: number): Rgb {
+ const a = Math.max(0, Math.min(1, alpha));
+ return {
+ r: clampByte(foreground.r * a + background.r * (1 - a)),
+ g: clampByte(foreground.g * a + background.g * (1 - a)),
+ b: clampByte(foreground.b * a + background.b * (1 - a)),
+ };
+}
+
+/**
+ * Mix class color hex against page background into an opaque chip background.
+ * Replaces the old `color + '2d'` translucent approach.
+ *
+ * @param classColorHex Class accent (e.g. `#45818e`)
+ * @param pageBgHexOrFlag Page `--color-bg` hex, or `"light"` / `"dark"` / boolean
+ * @param alpha Mix weight for class color (default matches legacy `2d`)
+ */
+export function mixClassChipBackground(
+ classColorHex: string,
+ pageBgHexOrFlag: string | ThemeBgFlag = "light",
+ alpha: number = CLASS_CHIP_MIX_ALPHA,
+): string {
+ const fg = parseHex(classColorHex);
+ const bg = parseHex(resolvePageBg(pageBgHexOrFlag));
+ if (!fg || !bg) {
+ return rgbToHex(bg || { r: 255, g: 255, b: 255 });
+ }
+ return rgbToHex(mixRgb(fg, bg, alpha));
+}
+
+/** sRGB channel → linear for relative luminance (WCAG). */
+function srgbChannelToLinear(c: number): number {
+ const s = c / 255;
+ return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
+}
+
+/** Relative luminance 0–1 (WCAG 2.x). */
+export function relativeLuminance(rgb: Rgb): number {
+ const R = srgbChannelToLinear(rgb.r);
+ const G = srgbChannelToLinear(rgb.g);
+ const B = srgbChannelToLinear(rgb.b);
+ return 0.2126 * R + 0.7152 * G + 0.0722 * B;
+}
+
+/** WCAG contrast ratio between two colors (≥ 1). */
+export function contrastRatio(a: Rgb, b: Rgb): number {
+ const L1 = relativeLuminance(a);
+ const L2 = relativeLuminance(b);
+ const lighter = Math.max(L1, L2);
+ const darker = Math.min(L1, L2);
+ return (lighter + 0.05) / (darker + 0.05);
+}
+
+/** Linear interpolate RGB toward `toward` by `t` in [0, 1]. */
+function lerpRgb(from: Rgb, toward: Rgb, t: number): Rgb {
+ return {
+ r: clampByte(from.r + (toward.r - from.r) * t),
+ g: clampByte(from.g + (toward.g - from.g) * t),
+ b: clampByte(from.b + (toward.b - from.b) * t),
+ };
+}
+
+/**
+ * Search along a lerp path from `base` toward `target` for best contrast vs `bg`.
+ * Prefers the first sample meeting `minRatio`; otherwise returns the best found.
+ */
+function bestAlongPath(
+ base: Rgb,
+ target: Rgb,
+ bg: Rgb,
+ minRatio: number,
+ steps: number = 24,
+): { color: Rgb; ratio: number } {
+ let best = { color: base, ratio: contrastRatio(base, bg) };
+ if (best.ratio >= minRatio) return best;
+ for (let i = 1; i <= steps; i++) {
+ const candidate = lerpRgb(base, target, i / steps);
+ const ratio = contrastRatio(candidate, bg);
+ if (ratio > best.ratio) {
+ best = { color: candidate, ratio };
+ }
+ if (ratio >= minRatio) {
+ return { color: candidate, ratio };
+ }
+ }
+ return best;
+}
+
+/**
+ * Pick a readable text/fg color against a mixed chip background.
+ * Tries the class color first; if contrast is below target, darkens or lightens
+ * toward black/white to approach WCAG ~4.5:1 for normal text when possible.
+ *
+ * @param classColorHex Class accent hex
+ * @param mixedBgHex Opaque chip background (from {@link mixClassChipBackground})
+ * @param minRatio Contrast target (default 4.5)
+ */
+export function readableTextOnBackground(
+ classColorHex: string,
+ mixedBgHex: string,
+ minRatio: number = CONTRAST_TARGET,
+): string {
+ const classRgb = parseHex(classColorHex);
+ const bgRgb = parseHex(mixedBgHex);
+ if (!bgRgb) return classColorHex || "#000000";
+ if (!classRgb) {
+ // Fall back to black or white by bg luminance
+ return relativeLuminance(bgRgb) > 0.179 ? "#000000" : "#ffffff";
+ }
+
+ const baseRatio = contrastRatio(classRgb, bgRgb);
+ if (baseRatio >= minRatio) {
+ return rgbToHex(classRgb);
+ }
+
+ const black = { r: 0, g: 0, b: 0 };
+ const white = { r: 255, g: 255, b: 255 };
+ const towardDark = bestAlongPath(classRgb, black, bgRgb, minRatio);
+ const towardLight = bestAlongPath(classRgb, white, bgRgb, minRatio);
+
+ // Prefer a result that meets the target; otherwise the higher ratio.
+ // If both meet (or neither), prefer the path that stays closer to the class hue
+ // by choosing the better ratio, breaking ties toward darkening on light bgs.
+ const bgLum = relativeLuminance(bgRgb);
+ let chosen: { color: Rgb; ratio: number };
+ if (towardDark.ratio >= minRatio && towardLight.ratio >= minRatio) {
+ chosen = bgLum > 0.179 ? towardDark : towardLight;
+ } else if (towardDark.ratio >= minRatio) {
+ chosen = towardDark;
+ } else if (towardLight.ratio >= minRatio) {
+ chosen = towardLight;
+ } else {
+ chosen = towardDark.ratio >= towardLight.ratio ? towardDark : towardLight;
+ }
+
+ // If adjusting away from class color still loses to pure black/white, use those.
+ const pureDark = { color: black, ratio: contrastRatio(black, bgRgb) };
+ const pureLight = { color: white, ratio: contrastRatio(white, bgRgb) };
+ if (chosen.ratio < minRatio) {
+ const pure = pureDark.ratio >= pureLight.ratio ? pureDark : pureLight;
+ if (pure.ratio > chosen.ratio) chosen = pure;
+ }
+
+ return rgbToHex(chosen.color);
+}
+
+/**
+ * Convenience: opaque chip bg + readable fg for a class color on a page bg.
+ */
+export function classChipColors(
+ classColorHex: string,
+ pageBgHexOrFlag: string | ThemeBgFlag = "light",
+ alpha: number = CLASS_CHIP_MIX_ALPHA,
+ minRatio: number = CONTRAST_TARGET,
+): { background: string; foreground: string; classColor: string } {
+ const classColor = parseHex(classColorHex)
+ ? rgbToHex(parseHex(classColorHex) as Rgb)
+ : String(classColorHex || "#000000");
+ const background = mixClassChipBackground(classColorHex, pageBgHexOrFlag, alpha);
+ const foreground = readableTextOnBackground(classColor, background, minRatio);
+ return { background, foreground, classColor };
+}
diff --git a/src/components/Portal/ClassNameChip.vue b/src/components/Portal/ClassNameChip.vue
new file mode 100644
index 00000000..75b28b0f
--- /dev/null
+++ b/src/components/Portal/ClassNameChip.vue
@@ -0,0 +1,144 @@
+
+