|
1 | 1 | /** |
2 | | - * Pick a readable text color (black or white) for text drawn ON TOP of a given |
3 | | - * background color. |
| 2 | + * Pick the text color for the sidebar header badge (a bold label drawn on a |
| 3 | + * `theme.accent` background). |
4 | 4 | * |
5 | | - * The sidebar header badge previously drew its label with `fg={theme.background}` |
6 | | - * on a `theme.accent` background. That breaks for themes that set |
7 | | - * `background: "none"` (transparent) to respect terminal transparency: the |
8 | | - * resolved background is `RGBA(0,0,0,0)`, so the badge text renders fully |
9 | | - * transparent and disappears (issue #186). The badge background (`accent`) is |
10 | | - * always opaque, so deriving the text color from it is transparency-proof. |
| 5 | + * Primary rule (matches AFT's sidebar by construction): paint the theme's own |
| 6 | + * `background` color as the label, the inverse-of-panel look. Because it is a |
| 7 | + * fixed theme token rather than an accent-derived computation, MC's badge and |
| 8 | + * AFT's badge agree on EVERY accent automatically, so the same theme can never |
| 9 | + * make one badge black and the other white (issue #198). |
11 | 10 | * |
12 | | - * The pick is WHITE-BIASED off the accent's relative luminance: white for any |
13 | | - * accent in the dark half (luminance < 0.5), black only for genuinely light |
14 | | - * accents. A strict "higher-contrast-wins" pick (crossover at luminance 0.179) |
15 | | - * flips ordinary mid-tone accents to black: a typical orange/amber sidebar |
16 | | - * accent (luminance ~0.3) reads black ~5:1 vs white ~3.7:1, so contrast-wins |
17 | | - * picks black even though white at ~3.7:1 is perfectly legible for a short bold |
18 | | - * label. That looks heavy and clashes with the sibling status badges, so we |
19 | | - * prefer white across the whole dark half and only fall to black once the accent |
20 | | - * is actually light (pale/pastel/near-white), where white would be unreadable. |
| 11 | + * Fallback rule (the reason a luminance pick exists at all): `theme.background` |
| 12 | + * can be unusable as a label color in two degenerate cases, where it would |
| 13 | + * render the label invisible on the accent: |
| 14 | + * 1. Transparent background. Themes that respect terminal transparency set |
| 15 | + * `background: "none"`, which resolves to `RGBA(0,0,0,0)`; drawing it as |
| 16 | + * text renders fully transparent and the label disappears (issue #186). |
| 17 | + * 2. Background ~= accent. If the theme's background and accent are nearly the |
| 18 | + * same color, background-on-accent text has no contrast. |
| 19 | + * In either case we fall back to a black/white pick that is guaranteed visible |
| 20 | + * on the always-opaque accent. |
21 | 21 | * |
22 | | - * `RGBA` channels from @opentui/core are normalized 0..1 floats. We accept the |
23 | | - * minimal `{ r, g, b }` shape so this stays a pure, trivially testable function |
24 | | - * independent of the native color class. |
| 22 | + * `RGBA` channels from @opentui/core are normalized 0..1 floats (alpha included). |
| 23 | + * We accept the minimal `{ r, g, b, a? }` shape so this stays a pure, trivially |
| 24 | + * testable function independent of the native color class, and we return the |
| 25 | + * passed-in `background` object unchanged on the primary path so it stays the |
| 26 | + * exact same theme token AFT uses. |
25 | 27 | */ |
26 | 28 |
|
27 | | -// Luminance midpoint: accents below this keep white text, accents at/above it |
28 | | -// (light/pastel/near-white) get black. White-biased relative to the strict |
29 | | -// equal-contrast crossover (~0.179) so saturated mid-tone accents stay white. |
| 29 | +type Color = { r: number; g: number; b: number; a?: number }; |
| 30 | + |
| 31 | +// Below this alpha the theme background is too transparent to read as a label on |
| 32 | +// the accent (issue #186: background:"none" resolves to alpha 0). |
| 33 | +const MIN_OPAQUE_ALPHA = 0.5; |
| 34 | + |
| 35 | +// If the theme background and accent are within this per-channel distance they |
| 36 | +// are effectively the same color, so background-on-accent text is unreadable. |
| 37 | +const MIN_CHANNEL_DISTANCE = 0.06; |
| 38 | + |
| 39 | +// Luminance midpoint for the fallback pick: accents below this keep white text, |
| 40 | +// accents at/above it (light/pastel/near-white) get black. White-biased relative |
| 41 | +// to the strict equal-contrast crossover (~0.179) so saturated mid-tone accents |
| 42 | +// stay white. Only consulted on the degenerate fallback path. |
30 | 43 | const LIGHT_ACCENT_LUMINANCE = 0.5; |
31 | 44 |
|
32 | 45 | function srgbChannelToLinear(c: number): number { |
33 | 46 | return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; |
34 | 47 | } |
35 | 48 |
|
36 | | -function relativeLuminance(bg: { r: number; g: number; b: number }): number { |
| 49 | +function relativeLuminance(bg: Color): number { |
37 | 50 | return ( |
38 | 51 | 0.2126 * srgbChannelToLinear(bg.r) + |
39 | 52 | 0.7152 * srgbChannelToLinear(bg.g) + |
40 | 53 | 0.0722 * srgbChannelToLinear(bg.b) |
41 | 54 | ); |
42 | 55 | } |
43 | 56 |
|
44 | | -export function readableTextColorOn(bg: { r: number; g: number; b: number }): string { |
| 57 | +function nearlyEqual(a: Color, b: Color): boolean { |
| 58 | + return ( |
| 59 | + Math.abs(a.r - b.r) < MIN_CHANNEL_DISTANCE && |
| 60 | + Math.abs(a.g - b.g) < MIN_CHANNEL_DISTANCE && |
| 61 | + Math.abs(a.b - b.b) < MIN_CHANNEL_DISTANCE |
| 62 | + ); |
| 63 | +} |
| 64 | + |
| 65 | +/** |
| 66 | + * Pure black/white pick by accent luminance. Used as the badge fallback and kept |
| 67 | + * exported for callers that only have the accent. |
| 68 | + */ |
| 69 | +export function readableTextColorOn(bg: Color): string { |
45 | 70 | return relativeLuminance(bg) < LIGHT_ACCENT_LUMINANCE ? "#ffffff" : "#000000"; |
46 | 71 | } |
| 72 | + |
| 73 | +/** |
| 74 | + * Badge label color on the accent: the theme background (AFT parity) when it is |
| 75 | + * usable, else a guaranteed-visible black/white fallback. Returns the passed-in |
| 76 | + * `background` reference unchanged on the primary path. |
| 77 | + */ |
| 78 | +export function badgeTextColor<T extends Color>(accent: T, background: T): T | string { |
| 79 | + const alpha = background.a ?? 1; |
| 80 | + if (alpha >= MIN_OPAQUE_ALPHA && !nearlyEqual(accent, background)) { |
| 81 | + return background; |
| 82 | + } |
| 83 | + return readableTextColorOn(accent); |
| 84 | +} |
0 commit comments