From 6ff9ff0cb9f659f2c364fff9c414f8280ce47b3b Mon Sep 17 00:00:00 2001 From: Nathanial Henniges <19924836+nathanialhenniges@users.noreply.github.com> Date: Sun, 16 Aug 2026 06:35:35 +0000 Subject: [PATCH 1/2] fix(chat): correct name colors against the outline in bg=off readableUserColor returns the color untouched when it has no surface to measure against, which is exactly the bg=off case, so Twitch's darker defaults rendered at full darkness over dark gameplay no matter how heavy the outline got. In transparent mode the ring is what the glyph actually reads against, so names now correct until they clear 4.5:1 against their own ring. Seven of the fifteen Twitch defaults failed that before; hot pink measured 2.65:1. Correction never crosses the light/dark threshold, so the ring a name is measured against is the ring it gets, and tests cover both properties. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/chat/chat-message.tsx | 13 ++++- apps/web/src/lib/twitch/colors.test.ts | 55 ++++++++++++++++++- apps/web/src/lib/twitch/colors.ts | 26 ++++++++- 3 files changed, 90 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/chat/chat-message.tsx b/apps/web/src/components/chat/chat-message.tsx index adf4f82..dc52d23 100644 --- a/apps/web/src/components/chat/chat-message.tsx +++ b/apps/web/src/components/chat/chat-message.tsx @@ -2,7 +2,11 @@ import { type CSSProperties, memo } from "react"; import { emoteOnlyCount, groupParts } from "@/lib/emotes/resolve"; import type { OverlayParams } from "@/lib/overlay/params"; -import { readableUserColor, userColorOutline } from "@/lib/twitch/colors"; +import { + outlinedUserColor, + readableUserColor, + userColorOutline, +} from "@/lib/twitch/colors"; import { isStandaloneEvent } from "@/lib/twitch/events"; import type { ChatMessageView } from "@/lib/twitch/types"; @@ -88,7 +92,12 @@ export const ChatMessageRow = memo(function ChatMessageRow({ bg === "off" ? "[text-shadow:var(--hb-shadow-off)]" : "[text-shadow:var(--hb-glow)]"; - const color = readableUserColor(message.color, surfaceColor); + // bg=off corrects against the outline ring (the only thing behind the + // glyph we control); panel and bubble correct against the theme surface + const color = + bg === "off" + ? outlinedUserColor(message.color) + : readableUserColor(message.color, surfaceColor); const nameStyle = { color, ...(bg === "off" ? { textShadow: userColorOutline(color) } : {}), diff --git a/apps/web/src/lib/twitch/colors.test.ts b/apps/web/src/lib/twitch/colors.test.ts index 7e48b10..540cfe6 100644 --- a/apps/web/src/lib/twitch/colors.test.ts +++ b/apps/web/src/lib/twitch/colors.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { readableUserColor, userColorOutline } from "./colors"; +import { + outlinedUserColor, + readableUserColor, + userColorOutline, +} from "./colors"; function rgb(hex: string): [number, number, number] { const value = hex.replace("#", ""); @@ -88,6 +92,55 @@ describe("readableUserColor", () => { }); }); +// Twitch's fifteen default name colors, the ones a chatter who never +// picked one gets assigned. +const TWITCH_DEFAULTS = [ + "#FF0000", + "#0000FF", + "#00FF7F", + "#B22222", + "#FF7F50", + "#9ACD32", + "#FF4500", + "#2E8B57", + "#DAA520", + "#D2691E", + "#5F9EA0", + "#1E90FF", + "#FF69B4", + "#8A2BE2", + "#00FF00", +]; + +describe("outlinedUserColor", () => { + // bg=off has no surface, so the ring is what the glyph reads against. + // Seven of the fifteen defaults failed this before the correction + // existed; hot pink on its white ring measured 2.65:1. + test("every Twitch default clears AA against its own outline ring", () => { + for (const color of TWITCH_DEFAULTS) { + const corrected = outlinedUserColor(color); + const ring = userColorOutline(corrected).includes("255 255 255") + ? "#ffffff" + : "#000000"; + expect(contrast(corrected, ring)).toBeGreaterThanOrEqual(4.5); + } + }); + + // If correcting a color pushed it across the light/dark threshold it + // would be measured against one ring and then handed the other. + test("correction never moves a name onto the opposite ring", () => { + for (const color of TWITCH_DEFAULTS) { + expect(userColorOutline(outlinedUserColor(color))).toBe( + userColorOutline(color), + ); + } + }); + + test("leaves invalid values alone", () => { + expect(outlinedUserColor("currentColor")).toBe("currentColor"); + }); +}); + describe("userColorOutline", () => { test("puts a light edge around dark names", () => { expect(userColorOutline("#000080")).toContain("255 255 255"); diff --git a/apps/web/src/lib/twitch/colors.ts b/apps/web/src/lib/twitch/colors.ts index 3292030..10626f6 100644 --- a/apps/web/src/lib/twitch/colors.ts +++ b/apps/web/src/lib/twitch/colors.ts @@ -118,6 +118,11 @@ const DARK_TEXT_OUTLINE = const LIGHT_TEXT_OUTLINE = "-1px -1px 0 rgb(0 0 0 / 0.95), 1px -1px 0 rgb(0 0 0 / 0.95), -1px 1px 0 rgb(0 0 0 / 0.95), 1px 1px 0 rgb(0 0 0 / 0.95), 0 -1px 0 rgb(0 0 0 / 0.95), 0 1px 0 rgb(0 0 0 / 0.95), -1px 0 0 rgb(0 0 0 / 0.95), 1px 0 0 rgb(0 0 0 / 0.95), 0 1px 3px rgb(0 0 0 / 0.7)"; +// Below this luminance a name is outlined in white, above it in black. +// Shared by the outline and the bg=off color correction so the two can +// never disagree about which ring a given name gets. +const OUTLINE_FLIP = 0.35; + // A transparent page cannot know the gameplay color OBS will composite // behind it. Give each dynamic name the opposite-luminance outline. export function userColorOutline(color: string): string { @@ -125,7 +130,26 @@ export function userColorOutline(color: string): string { if (!rgb) { return LIGHT_TEXT_OUTLINE; } - return relativeLuminance(rgb) < 0.35 ? DARK_TEXT_OUTLINE : LIGHT_TEXT_OUTLINE; + return relativeLuminance(rgb) < OUTLINE_FLIP + ? DARK_TEXT_OUTLINE + : LIGHT_TEXT_OUTLINE; +} + +// bg=off has no surface to measure against, so readableUserColor cannot +// correct anything and Twitch's darkest defaults used to render at full +// darkness over dark gameplay. The outline ring is what the glyph +// actually reads against there, so correct the color until it clears AA +// against its own ring: a dark name wearing a white ring darkens further, +// a light name wearing a black ring lightens. Correction never crosses +// OUTLINE_FLIP, so the ring it was measured against stays the ring it +// gets. +export function outlinedUserColor(color: string): string { + const rgb = parseHex(color); + if (!rgb) { + return color; + } + const ring = relativeLuminance(rgb) < OUTLINE_FLIP ? "#ffffff" : "#000000"; + return readableUserColor(color, ring); } export function fallbackColor(login: string): string { From 1e31d5161e04db3175d85fbacb2eee490de61162 Mon Sep 17 00:00:00 2001 From: Nathanial Henniges <19924836+nathanialhenniges@users.noreply.github.com> Date: Sun, 16 Aug 2026 06:43:19 +0000 Subject: [PATCH 2/2] fix(chat): measure name contrast against the composited outline Review catch: the ring paints at 0.95 alpha, so measuring the correction against opaque white or black overstated what a viewer actually gets. A white ring bottoms out at 0.95 white over black and a black ring tops out at 0.95 black over white, so the correction now targets those two greys. The test also reuses the palette the file already declares instead of a hand-typed list that had drifted from it. Co-Authored-By: Claude Fable 5 --- apps/web/src/lib/twitch/colors.test.ts | 24 ++---------------------- apps/web/src/lib/twitch/colors.ts | 17 ++++++++++++++++- 2 files changed, 18 insertions(+), 23 deletions(-) diff --git a/apps/web/src/lib/twitch/colors.test.ts b/apps/web/src/lib/twitch/colors.test.ts index 540cfe6..0c743c0 100644 --- a/apps/web/src/lib/twitch/colors.test.ts +++ b/apps/web/src/lib/twitch/colors.test.ts @@ -92,32 +92,12 @@ describe("readableUserColor", () => { }); }); -// Twitch's fifteen default name colors, the ones a chatter who never -// picked one gets assigned. -const TWITCH_DEFAULTS = [ - "#FF0000", - "#0000FF", - "#00FF7F", - "#B22222", - "#FF7F50", - "#9ACD32", - "#FF4500", - "#2E8B57", - "#DAA520", - "#D2691E", - "#5F9EA0", - "#1E90FF", - "#FF69B4", - "#8A2BE2", - "#00FF00", -]; - describe("outlinedUserColor", () => { // bg=off has no surface, so the ring is what the glyph reads against. // Seven of the fifteen defaults failed this before the correction // existed; hot pink on its white ring measured 2.65:1. test("every Twitch default clears AA against its own outline ring", () => { - for (const color of TWITCH_DEFAULTS) { + for (const color of TWITCH_COLORS) { const corrected = outlinedUserColor(color); const ring = userColorOutline(corrected).includes("255 255 255") ? "#ffffff" @@ -129,7 +109,7 @@ describe("outlinedUserColor", () => { // If correcting a color pushed it across the light/dark threshold it // would be measured against one ring and then handed the other. test("correction never moves a name onto the opposite ring", () => { - for (const color of TWITCH_DEFAULTS) { + for (const color of TWITCH_COLORS) { expect(userColorOutline(outlinedUserColor(color))).toBe( userColorOutline(color), ); diff --git a/apps/web/src/lib/twitch/colors.ts b/apps/web/src/lib/twitch/colors.ts index 10626f6..d492aa6 100644 --- a/apps/web/src/lib/twitch/colors.ts +++ b/apps/web/src/lib/twitch/colors.ts @@ -123,6 +123,16 @@ const LIGHT_TEXT_OUTLINE = // never disagree about which ring a given name gets. const OUTLINE_FLIP = 0.35; +// Alpha the outline rings above are painted at. Composited over a +// backdrop we cannot see, a white ring is never lighter than 0.95 white +// over black, and a black ring is never darker than 0.95 black over +// white, so those two greys are the honest measurement targets. +const OUTLINE_ALPHA = 0.95; +const greyHex = (value: number) => + `#${Math.round(value).toString(16).padStart(2, "0").repeat(3)}`; +const WORST_CASE_LIGHT_RING = greyHex(255 * OUTLINE_ALPHA); +const WORST_CASE_DARK_RING = greyHex(255 * (1 - OUTLINE_ALPHA)); + // A transparent page cannot know the gameplay color OBS will composite // behind it. Give each dynamic name the opposite-luminance outline. export function userColorOutline(color: string): string { @@ -148,7 +158,12 @@ export function outlinedUserColor(color: string): string { if (!rgb) { return color; } - const ring = relativeLuminance(rgb) < OUTLINE_FLIP ? "#ffffff" : "#000000"; + // Measured against the ring's worst-case composite rather than pure + // white or black, which would overstate the contrast a viewer gets. + const ring = + relativeLuminance(rgb) < OUTLINE_FLIP + ? WORST_CASE_LIGHT_RING + : WORST_CASE_DARK_RING; return readableUserColor(color, ring); }