fix: improve pill contrast by using WCAG contrast ratios on composited backgrounds
Pills with semi-transparent backgrounds were using raw color luminance to pick text color, ignoring the page background showing through. This caused unreadable text on dark themes for mid-luminance colors like orange. Now composites the rgba background over the actual page bg (dark/light) before computing WCAG contrast ratios, and centralizes the logic in a shared color-contrast utility. Co-Authored-By: Paperclip <noreply@paperclip.ing> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e73bc81a73
commit
d73c8df895
6 changed files with 118 additions and 27 deletions
|
|
@ -1,4 +1,5 @@
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
|
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
||||||
import { Link } from "@/lib/router";
|
import { Link } from "@/lib/router";
|
||||||
import type { Issue } from "@paperclipai/shared";
|
import type { Issue } from "@paperclipai/shared";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
@ -329,7 +330,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
||||||
style={{
|
style={{
|
||||||
borderColor: label.color,
|
borderColor: label.color,
|
||||||
backgroundColor: `${label.color}22`,
|
backgroundColor: `${label.color}22`,
|
||||||
color: label.color,
|
color: pickTextColorForPillBg(label.color, 0.13),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{label.name}
|
{label.name}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useEffect, useMemo, useState, useCallback, useRef } from "react";
|
import { useEffect, useMemo, useState, useCallback, useRef } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { issuesApi } from "../api/issues";
|
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"
|
className="inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium"
|
||||||
style={{
|
style={{
|
||||||
borderColor: label.color,
|
borderColor: label.color,
|
||||||
color: label.color,
|
color: pickTextColorForPillBg(label.color, 0.12),
|
||||||
backgroundColor: `${label.color}1f`,
|
backgroundColor: `${label.color}1f`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent, type DragEvent } from "react";
|
import { useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent, type DragEvent } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { pickTextColorForSolidBg } from "@/lib/color-contrast";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||||
|
|
@ -56,15 +57,6 @@ import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySel
|
||||||
const DRAFT_KEY = "paperclip:issue-draft";
|
const DRAFT_KEY = "paperclip:issue-draft";
|
||||||
const DEBOUNCE_MS = 800;
|
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 {
|
interface IssueDraft {
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -915,7 +907,7 @@ export function NewIssueDialog() {
|
||||||
dialogCompany?.brandColor
|
dialogCompany?.brandColor
|
||||||
? {
|
? {
|
||||||
backgroundColor: dialogCompany.brandColor,
|
backgroundColor: dialogCompany.brandColor,
|
||||||
color: getContrastTextColor(dialogCompany.brandColor),
|
color: pickTextColorForSolidBg(dialogCompany.brandColor),
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
|
@ -945,7 +937,7 @@ export function NewIssueDialog() {
|
||||||
c.brandColor
|
c.brandColor
|
||||||
? {
|
? {
|
||||||
backgroundColor: c.brandColor,
|
backgroundColor: c.brandColor,
|
||||||
color: getContrastTextColor(c.brandColor),
|
color: pickTextColorForSolidBg(c.brandColor),
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
|
|
||||||
107
ui/src/lib/color-contrast.ts
Normal file
107
ui/src/lib/color-contrast.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { parseAgentMentionHref, parseProjectMentionHref } from "@paperclipai/shared";
|
import { parseAgentMentionHref, parseProjectMentionHref } from "@paperclipai/shared";
|
||||||
import { getAgentIcon } from "./agent-icons";
|
import { getAgentIcon } from "./agent-icons";
|
||||||
|
import { hexToRgb, pickTextColorForPillBg } from "./color-contrast";
|
||||||
|
|
||||||
export type ParsedMentionChip =
|
export type ParsedMentionChip =
|
||||||
| {
|
| {
|
||||||
|
|
@ -98,22 +99,10 @@ export function clearMentionChipDecoration(element: HTMLElement) {
|
||||||
function projectMentionColors(color: string): Pick<CSSProperties, "borderColor" | "backgroundColor" | "color"> {
|
function projectMentionColors(color: string): Pick<CSSProperties, "borderColor" | "backgroundColor" | "color"> {
|
||||||
const rgb = hexToRgb(color);
|
const rgb = hexToRgb(color);
|
||||||
if (!rgb) return {};
|
if (!rgb) return {};
|
||||||
const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
|
|
||||||
return {
|
return {
|
||||||
borderColor: color,
|
borderColor: color,
|
||||||
backgroundColor: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.22)`,
|
backgroundColor: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.22)`,
|
||||||
color: luminance > 0.55 ? "#111827" : "#f8fafc",
|
color: pickTextColorForPillBg(color),
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent } from "react";
|
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 { Link, useLocation, useNavigate, useParams } from "@/lib/router";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { issuesApi } from "../api/issues";
|
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"
|
className="inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium"
|
||||||
style={{
|
style={{
|
||||||
borderColor: label.color,
|
borderColor: label.color,
|
||||||
color: label.color,
|
color: pickTextColorForPillBg(label.color, 0.12),
|
||||||
backgroundColor: `${label.color}1f`,
|
backgroundColor: `${label.color}1f`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue