diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index 053e8e42..3b8bb25e 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -1,4 +1,5 @@ import { useCallback, useMemo, useRef, useState } from "react"; +import { pickTextColorForPillBg } from "@/lib/color-contrast"; import { Link } from "@/lib/router"; import type { Issue } from "@paperclipai/shared"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -329,7 +330,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp style={{ borderColor: label.color, backgroundColor: `${label.color}22`, - color: label.color, + color: pickTextColorForPillBg(label.color, 0.13), }} > {label.name} diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index c7f75f1e..b5c49ebc 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState, useCallback, useRef } from "react"; import { useQuery } from "@tanstack/react-query"; +import { pickTextColorForPillBg } from "@/lib/color-contrast"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; import { issuesApi } from "../api/issues"; @@ -719,7 +720,7 @@ export function IssuesList({ className="inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium" style={{ borderColor: label.color, - color: label.color, + color: pickTextColorForPillBg(label.color, 0.12), backgroundColor: `${label.color}1f`, }} > diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 6b8519cd..e20b0db1 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent, type DragEvent } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { pickTextColorForSolidBg } from "@/lib/color-contrast"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; import { executionWorkspacesApi } from "../api/execution-workspaces"; @@ -56,15 +57,6 @@ import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySel const DRAFT_KEY = "paperclip:issue-draft"; const DEBOUNCE_MS = 800; -/** Return black or white hex based on background luminance (WCAG perceptual weights). */ -function getContrastTextColor(hexColor: string): string { - const hex = hexColor.replace("#", ""); - const r = parseInt(hex.substring(0, 2), 16); - const g = parseInt(hex.substring(2, 4), 16); - const b = parseInt(hex.substring(4, 6), 16); - const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; - return luminance > 0.5 ? "#000000" : "#ffffff"; -} interface IssueDraft { title: string; @@ -915,7 +907,7 @@ export function NewIssueDialog() { dialogCompany?.brandColor ? { backgroundColor: dialogCompany.brandColor, - color: getContrastTextColor(dialogCompany.brandColor), + color: pickTextColorForSolidBg(dialogCompany.brandColor), } : undefined } @@ -945,7 +937,7 @@ export function NewIssueDialog() { c.brandColor ? { backgroundColor: c.brandColor, - color: getContrastTextColor(c.brandColor), + color: pickTextColorForSolidBg(c.brandColor), } : undefined } diff --git a/ui/src/lib/color-contrast.ts b/ui/src/lib/color-contrast.ts new file mode 100644 index 00000000..70b2296a --- /dev/null +++ b/ui/src/lib/color-contrast.ts @@ -0,0 +1,107 @@ +/** + * Shared color-contrast utilities for pill / badge / chip components. + * + * Uses WCAG 2.1 relative-luminance contrast ratios so text is always + * readable, even on semi-transparent backgrounds composited over dark or + * light page backgrounds. + */ + +const DARK_BG = { r: 24, g: 24, b: 27 }; // zinc-900 (#18181b) +const LIGHT_BG = { r: 255, g: 255, b: 255 }; // white + +export function hexToRgb(hex: string): { r: number; g: number; b: number } | null { + const match = /^#?([0-9a-f]{3,6})$/i.exec(hex.trim()); + if (!match) return null; + let value = match[1]; + if (value.length === 3) { + value = value + .split("") + .map((c) => `${c}${c}`) + .join(""); + } + if (value.length !== 6) return null; + return { + r: parseInt(value.slice(0, 2), 16), + g: parseInt(value.slice(2, 4), 16), + b: parseInt(value.slice(4, 6), 16), + }; +} + +function relativeLuminanceChannel(value: number): number { + const normalized = value / 255; + return normalized <= 0.03928 + ? normalized / 12.92 + : ((normalized + 0.055) / 1.055) ** 2.4; +} + +function relativeLuminance(r: number, g: number, b: number): number { + return ( + 0.2126 * relativeLuminanceChannel(r) + + 0.7152 * relativeLuminanceChannel(g) + + 0.0722 * relativeLuminanceChannel(b) + ); +} + +function contrastRatio(l1: number, l2: number): number { + const lighter = Math.max(l1, l2); + const darker = Math.min(l1, l2); + return (lighter + 0.05) / (darker + 0.05); +} + +function isDarkMode(): boolean { + if (typeof document === "undefined") return true; + return document.documentElement.classList.contains("dark"); +} + +/** + * Composite a foreground RGB at the given alpha over a background RGB. + */ +function composite( + fg: { r: number; g: number; b: number }, + bg: { r: number; g: number; b: number }, + alpha: number, +): { r: number; g: number; b: number } { + return { + r: Math.round(alpha * fg.r + (1 - alpha) * bg.r), + g: Math.round(alpha * fg.g + (1 - alpha) * bg.g), + b: Math.round(alpha * fg.b + (1 - alpha) * bg.b), + }; +} + +const TEXT_LIGHT = "#f8fafc"; +const TEXT_DARK = "#111827"; + +/** + * Pick a readable text color for a solid background. + * Uses WCAG contrast ratios to choose between light and dark text. + */ +export function pickTextColorForSolidBg(hexColor: string): string { + const rgb = hexToRgb(hexColor); + if (!rgb) return TEXT_LIGHT; + const bgLum = relativeLuminance(rgb.r, rgb.g, rgb.b); + const whiteLum = relativeLuminance(248, 250, 252); + const blackLum = relativeLuminance(17, 24, 39); + return contrastRatio(bgLum, whiteLum) >= contrastRatio(bgLum, blackLum) + ? TEXT_LIGHT + : TEXT_DARK; +} + +/** + * Pick a readable text color for a semi-transparent pill background. + * + * Composites `rgba(hexColor, alpha)` over the current page background + * (dark or light mode) and then picks the text color with better + * WCAG contrast ratio. + */ +export function pickTextColorForPillBg(hexColor: string, alpha = 0.22): string { + const fg = hexToRgb(hexColor); + if (!fg) return TEXT_LIGHT; + const pageBg = isDarkMode() ? DARK_BG : LIGHT_BG; + const effectiveBg = composite(fg, pageBg, alpha); + const bgLum = relativeLuminance(effectiveBg.r, effectiveBg.g, effectiveBg.b); + const whiteLum = relativeLuminance(248, 250, 252); + const blackLum = relativeLuminance(17, 24, 39); + return contrastRatio(bgLum, whiteLum) >= contrastRatio(bgLum, blackLum) + ? TEXT_LIGHT + : TEXT_DARK; +} diff --git a/ui/src/lib/mention-chips.ts b/ui/src/lib/mention-chips.ts index d082cd7f..d00185a8 100644 --- a/ui/src/lib/mention-chips.ts +++ b/ui/src/lib/mention-chips.ts @@ -1,6 +1,7 @@ import type { CSSProperties } from "react"; import { parseAgentMentionHref, parseProjectMentionHref } from "@paperclipai/shared"; import { getAgentIcon } from "./agent-icons"; +import { hexToRgb, pickTextColorForPillBg } from "./color-contrast"; export type ParsedMentionChip = | { @@ -98,22 +99,10 @@ export function clearMentionChipDecoration(element: HTMLElement) { function projectMentionColors(color: string): Pick { const rgb = hexToRgb(color); if (!rgb) return {}; - const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255; return { borderColor: color, backgroundColor: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.22)`, - color: luminance > 0.55 ? "#111827" : "#f8fafc", - }; -} - -function hexToRgb(hex: string): { r: number; g: number; b: number } | null { - const match = /^#([0-9a-f]{6})$/i.exec(hex.trim()); - if (!match) return null; - const value = match[1]; - return { - r: parseInt(value.slice(0, 2), 16), - g: parseInt(value.slice(2, 4), 16), - b: parseInt(value.slice(4, 6), 16), + color: pickTextColorForPillBg(color), }; } diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index db99339b..ed23b055 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent } from "react"; +import { pickTextColorForPillBg } from "@/lib/color-contrast"; import { Link, useLocation, useNavigate, useParams } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { issuesApi } from "../api/issues"; @@ -767,7 +768,7 @@ export function IssueDetail() { className="inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium" style={{ borderColor: label.color, - color: label.color, + color: pickTextColorForPillBg(label.color, 0.12), backgroundColor: `${label.color}1f`, }} >