|
| 1 | +/** |
| 2 | + * Explicit-hex badge colour math (objectui#5141): one author-declared hex in, |
| 3 | + * the soft-pill and dot palette out. The rendering half (the static Tailwind |
| 4 | + * utility strings, `getBadgeHexAppearance`, `getDotHexAppearance`) and the |
| 5 | + * block's rationale live in `index.tsx`, the only reader. |
| 6 | + * |
| 7 | + * Not re-exported from the package entry: `getBadgeHexAppearance` and |
| 8 | + * `getDotHexAppearance` are the public faces of this palette. |
| 9 | + */ |
| 10 | + |
| 11 | +/** WCAG AA floor for badge label text against its own pill surface. */ |
| 12 | +const BADGE_TEXT_CONTRAST = 4.5; |
| 13 | + |
| 14 | +/** |
| 15 | + * Visibility floor for the `appearance: 'dot'` marker. A dot carries no text, |
| 16 | + * so the AA *text* ratio does not apply; this is the measured floor of the |
| 17 | + * -500 shades `DOT_COLOR_MAP` (in `index.tsx`) ships today (yellow-500 |
| 18 | + * `#eab308` is the weakest at 1.92:1 on white). Pinning it here means an author-declared dot is never |
| 19 | + * less visible than the palette dot it replaces. |
| 20 | + */ |
| 21 | +const DOT_CONTRAST_FLOOR = 1.9; |
| 22 | + |
| 23 | +interface Rgb { r: number; g: number; b: number } |
| 24 | + |
| 25 | +/** Surfaces a dot sits on — used only to keep the dot itself visible. */ |
| 26 | +const LIGHT_SURFACE: Rgb = { r: 255, g: 255, b: 255 }; |
| 27 | +const DARK_SURFACE: Rgb = { r: 10, g: 10, b: 10 }; |
| 28 | + |
| 29 | +const clampNum = (v: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, v)); |
| 30 | + |
| 31 | +function parseHexColor(hex: string): Rgb | undefined { |
| 32 | + const m = /^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.exec(hex.trim()); |
| 33 | + if (!m) return undefined; |
| 34 | + let h = m[1]; |
| 35 | + if (h.length === 3) h = h.split('').map((c) => c + c).join(''); |
| 36 | + return { |
| 37 | + r: parseInt(h.slice(0, 2), 16), |
| 38 | + g: parseInt(h.slice(2, 4), 16), |
| 39 | + b: parseInt(h.slice(4, 6), 16), |
| 40 | + }; |
| 41 | +} |
| 42 | + |
| 43 | +function rgbToHsl({ r, g, b }: Rgb): { h: number; s: number; l: number } { |
| 44 | + const rn = r / 255, gn = g / 255, bn = b / 255; |
| 45 | + const max = Math.max(rn, gn, bn); |
| 46 | + const min = Math.min(rn, gn, bn); |
| 47 | + const d = max - min; |
| 48 | + const l = (max + min) / 2; |
| 49 | + const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1)); |
| 50 | + let h = 0; |
| 51 | + if (d !== 0) { |
| 52 | + if (max === rn) h = (((gn - bn) / d) % 6 + 6) % 6; |
| 53 | + else if (max === gn) h = (bn - rn) / d + 2; |
| 54 | + else h = (rn - gn) / d + 4; |
| 55 | + h *= 60; |
| 56 | + } |
| 57 | + return { h, s, l }; |
| 58 | +} |
| 59 | + |
| 60 | +function hslToRgb(h: number, s: number, l: number): Rgb { |
| 61 | + const hue = ((h % 360) + 360) % 360; |
| 62 | + const c = (1 - Math.abs(2 * l - 1)) * s; |
| 63 | + const x = c * (1 - Math.abs(((hue / 60) % 2) - 1)); |
| 64 | + const m = l - c / 2; |
| 65 | + let p: [number, number, number]; |
| 66 | + if (hue < 60) p = [c, x, 0]; |
| 67 | + else if (hue < 120) p = [x, c, 0]; |
| 68 | + else if (hue < 180) p = [0, c, x]; |
| 69 | + else if (hue < 240) p = [0, x, c]; |
| 70 | + else if (hue < 300) p = [x, 0, c]; |
| 71 | + else p = [c, 0, x]; |
| 72 | + return { |
| 73 | + r: Math.round((p[0] + m) * 255), |
| 74 | + g: Math.round((p[1] + m) * 255), |
| 75 | + b: Math.round((p[2] + m) * 255), |
| 76 | + }; |
| 77 | +} |
| 78 | + |
| 79 | +const rgbToHex = ({ r, g, b }: Rgb): string => |
| 80 | + '#' + [r, g, b].map((v) => clampNum(Math.round(v), 0, 255).toString(16).padStart(2, '0')).join(''); |
| 81 | + |
| 82 | +/** WCAG relative luminance of an sRGB color. */ |
| 83 | +function relativeLuminance({ r, g, b }: Rgb): number { |
| 84 | + const channel = (v: number): number => { |
| 85 | + const c = v / 255; |
| 86 | + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); |
| 87 | + }; |
| 88 | + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); |
| 89 | +} |
| 90 | + |
| 91 | +/** WCAG contrast ratio between two colors (1..21). */ |
| 92 | +function contrastRatio(a: Rgb, b: Rgb): number { |
| 93 | + const la = relativeLuminance(a); |
| 94 | + const lb = relativeLuminance(b); |
| 95 | + return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05); |
| 96 | +} |
| 97 | + |
| 98 | +/** |
| 99 | + * Walk the declared hue for the lightness *closest to the declared one* that |
| 100 | + * still clears `target` against `surface`. |
| 101 | + * |
| 102 | + * Choosing the closest passing lightness rather than simply maximizing |
| 103 | + * contrast is what keeps the author's colour recognizable — maximizing would |
| 104 | + * collapse every badge label to black or white and re-lose the declaration we |
| 105 | + * are here to preserve. Only when the hue cannot reach the target at any |
| 106 | + * lightness (a very low-chroma declaration against a mid surface) do we fall |
| 107 | + * back to black or white, whichever is further from the surface: legibility |
| 108 | + * outranks fidelity. |
| 109 | + */ |
| 110 | +function readableOnSurface( |
| 111 | + h: number, |
| 112 | + s: number, |
| 113 | + declaredL: number, |
| 114 | + surface: Rgb, |
| 115 | + target: number, |
| 116 | +): Rgb { |
| 117 | + let best: Rgb | undefined; |
| 118 | + let bestDistance = Infinity; |
| 119 | + for (let step = 0; step <= 100; step++) { |
| 120 | + const lightness = step / 100; |
| 121 | + const candidate = hslToRgb(h, s, lightness); |
| 122 | + if (contrastRatio(candidate, surface) < target) continue; |
| 123 | + const distance = Math.abs(lightness - declaredL); |
| 124 | + if (distance < bestDistance) { |
| 125 | + bestDistance = distance; |
| 126 | + best = candidate; |
| 127 | + } |
| 128 | + } |
| 129 | + if (best) return best; |
| 130 | + const black: Rgb = { r: 0, g: 0, b: 0 }; |
| 131 | + const white: Rgb = { r: 255, g: 255, b: 255 }; |
| 132 | + return contrastRatio(black, surface) >= contrastRatio(white, surface) ? black : white; |
| 133 | +} |
| 134 | + |
| 135 | +/** The six pill colors plus the two dot colors derived from one declared hex. */ |
| 136 | +export interface HexBadgePalette { |
| 137 | + bg: string; |
| 138 | + fg: string; |
| 139 | + border: string; |
| 140 | + bgDark: string; |
| 141 | + fgDark: string; |
| 142 | + borderDark: string; |
| 143 | + dot: string; |
| 144 | + dotDark: string; |
| 145 | +} |
| 146 | + |
| 147 | +/** |
| 148 | + * Derive a full soft-pill palette from an author-declared hex. |
| 149 | + * |
| 150 | + * The declared LIGHTNESS is what separates two same-hue tiers (`#2ecc71` is |
| 151 | + * l=0.49, `#1e8449` is l=0.32 — their hues differ by 0.1°), so it has to |
| 152 | + * survive into the *surface*. A fixed pale tint — the obvious reading of |
| 153 | + * "compute a soft pill from the hex" — does not carry it: measured, that |
| 154 | + * leaves the reported pair ΔE 2.3 apart in Lab, at the ~2.3 just-noticeable |
| 155 | + * threshold, which would close this issue on paper while the user still cannot |
| 156 | + * tell the two badges apart. Letting the tint depth track the declared |
| 157 | + * lightness puts them ΔE 8.0 apart instead. |
| 158 | + */ |
| 159 | +export function deriveHexBadgePalette(color: string): HexBadgePalette | undefined { |
| 160 | + const rgb = parseHexColor(color); |
| 161 | + if (!rgb) return undefined; |
| 162 | + const { h, s, l } = rgbToHsl(rgb); |
| 163 | + // Keep a usable chroma at both ends: a fully desaturated declaration stays |
| 164 | + // neutral instead of being pushed into a hue it never declared, and a neon |
| 165 | + // one is reined in so the pill never fights the row it sits in. |
| 166 | + const sat = clampNum(s, 0.18, 0.92); |
| 167 | + |
| 168 | + // Light theme: pale for light declarations (the -50 look we ship today), |
| 169 | + // deepening as the declared colour darkens. |
| 170 | + const bgLightness = 0.95 - 0.32 * (1 - l); |
| 171 | + const bg = hslToRgb(h, sat * (0.55 + 0.25 * (1 - l)), bgLightness); |
| 172 | + const border = hslToRgb(h, sat * 0.62, clampNum(bgLightness - 0.14, 0, 1)); |
| 173 | + const fg = readableOnSurface(h, sat, l, bg, BADGE_TEXT_CONTRAST); |
| 174 | + |
| 175 | + // Dark theme: the same idea mirrored — a dark surface whose depth tracks the |
| 176 | + // declared lightness, with the label lifted until it clears AA against it. |
| 177 | + const bgDarkLightness = 0.10 + 0.30 * l; |
| 178 | + const bgDark = hslToRgb(h, sat * 0.6, bgDarkLightness); |
| 179 | + const borderDark = hslToRgb(h, sat * 0.55, clampNum(bgDarkLightness + 0.12, 0, 1)); |
| 180 | + const fgDark = readableOnSurface(h, sat, l, bgDark, BADGE_TEXT_CONTRAST); |
| 181 | + |
| 182 | + return { |
| 183 | + bg: rgbToHex(bg), |
| 184 | + fg: rgbToHex(fg), |
| 185 | + border: rgbToHex(border), |
| 186 | + bgDark: rgbToHex(bgDark), |
| 187 | + fgDark: rgbToHex(fgDark), |
| 188 | + borderDark: rgbToHex(borderDark), |
| 189 | + dot: rgbToHex(readableOnSurface(h, sat, l, LIGHT_SURFACE, DOT_CONTRAST_FLOOR)), |
| 190 | + dotDark: rgbToHex(readableOnSurface(h, sat, l, DARK_SURFACE, DOT_CONTRAST_FLOOR)), |
| 191 | + }; |
| 192 | +} |
0 commit comments