Skip to content

Commit 1d97bf8

Browse files
fix(tui): badge label uses theme background (AFT parity), fixes #198
The sidebar header badge derived its text color from the accent's luminance (white-biased), so on a dark theme with a purple/lavender accent it rendered WHITE while AFT's badge rendered BLACK on the same accent (AFT paints theme.background as the label). The mismatch between two sibling badges is the #198 complaint. Match AFT by construction: badgeTextColor() now returns theme.background as the label, the same fixed theme token AFT uses, so both badges agree on every accent automatically. Keep the #186 safety net: when theme.background is transparent (background:"none" resolves to alpha 0, which would render the label invisible) or nearly equal to the accent, fall back to a guaranteed-visible black/white pick on the always-opaque accent. readableTextColorOn is retained as that fallback. Gate: plugin TUI 11/0, tsc + biome clean. Co-authored-by: Alfonso [Magic Context] <288211368+alfonso-magic-context@users.noreply.github.com>
1 parent f637878 commit 1d97bf8

3 files changed

Lines changed: 104 additions & 28 deletions

File tree

packages/plugin/src/tui/badge-contrast.test.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,43 @@
11
import { describe, expect, test } from "bun:test";
2-
import { readableTextColorOn } from "./badge-contrast";
2+
import { badgeTextColor, readableTextColorOn } from "./badge-contrast";
3+
4+
describe("badgeTextColor (AFT parity with #186 safety net)", () => {
5+
// A purple/lavender accent on a dark theme: the badge label should be the
6+
// theme background (the inverse-of-panel look), the same fixed token the AFT
7+
// sidebar uses, so two sibling badges never disagree on the same accent.
8+
const accent = { r: 0.6, g: 0.5, b: 0.9, a: 1 };
9+
10+
test("opaque distinct background is used verbatim as the label", () => {
11+
const background = { r: 0.05, g: 0.05, b: 0.07, a: 1 }; // near-black dark theme
12+
// Returns the SAME background token rather than a computed color.
13+
expect(badgeTextColor(accent, background)).toBe(background);
14+
});
15+
16+
test("light theme: background is used verbatim too (near-white label inverse)", () => {
17+
const background = { r: 0.97, g: 0.97, b: 0.95, a: 1 };
18+
expect(badgeTextColor(accent, background)).toBe(background);
19+
});
20+
21+
test("transparent background (alpha 0) falls back to a visible pick (#186)", () => {
22+
// background:"none" resolves to RGBA(0,0,0,0); using it as text would
23+
// render the label invisible, so fall back to a contrast pick on accent.
24+
const transparent = { r: 0, g: 0, b: 0, a: 0 };
25+
const result = badgeTextColor(accent, transparent);
26+
expect(result).not.toBe(transparent);
27+
expect(result).toBe(readableTextColorOn(accent));
28+
});
29+
30+
test("background ~= accent falls back to a visible pick", () => {
31+
const sameAsAccent = { r: 0.6, g: 0.5, b: 0.9, a: 1 };
32+
const result = badgeTextColor(accent, sameAsAccent);
33+
expect(result).toBe(readableTextColorOn(accent));
34+
});
35+
36+
test("missing alpha is treated as opaque", () => {
37+
const background = { r: 0.05, g: 0.05, b: 0.07 };
38+
expect(badgeTextColor(accent, background)).toBe(background);
39+
});
40+
});
341

442
describe("readableTextColorOn", () => {
543
test("dark accent gets white text", () => {
Lines changed: 63 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,84 @@
11
/**
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).
44
*
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).
1110
*
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.
2121
*
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.
2527
*/
2628

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.
3043
const LIGHT_ACCENT_LUMINANCE = 0.5;
3144

3245
function srgbChannelToLinear(c: number): number {
3346
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
3447
}
3548

36-
function relativeLuminance(bg: { r: number; g: number; b: number }): number {
49+
function relativeLuminance(bg: Color): number {
3750
return (
3851
0.2126 * srgbChannelToLinear(bg.r) +
3952
0.7152 * srgbChannelToLinear(bg.g) +
4053
0.0722 * srgbChannelToLinear(bg.b)
4154
);
4255
}
4356

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 {
4570
return relativeLuminance(bg) < LIGHT_ACCENT_LUMINANCE ? "#ffffff" : "#000000";
4671
}
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+
}

packages/plugin/src/tui/slots/sidebar-content.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
33
import type { TuiSlotPlugin, TuiPluginApi, TuiThemeCurrent } from "@opencode-ai/plugin/tui"
44
import packageJson from "../../../package.json"
5-
import { readableTextColorOn } from '../badge-contrast';
5+
import { badgeTextColor } from '../badge-contrast';
66
import { loadSidebarSnapshot, type SidebarSnapshot } from "../data/context-db"
77
import { formatThresholdPercent } from "../../shared/format-threshold"
88
import {
@@ -695,7 +695,7 @@ const SidebarContent = (props: {
695695
onMouseDown={() => props.controller.toggleCollapsed()}
696696
>
697697
<box paddingLeft={1} paddingRight={1} backgroundColor={props.theme.accent}>
698-
<text fg={readableTextColorOn(props.theme.accent)}>
698+
<text fg={badgeTextColor(props.theme.accent, props.theme.background)}>
699699
<b>{collapsed() ? "▶ " : "▼ "}{headerLabel()}</b>
700700
</text>
701701
</box>

0 commit comments

Comments
 (0)