Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions apps/web/src/components/chat/chat-message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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) } : {}),
Expand Down
35 changes: 34 additions & 1 deletion apps/web/src/lib/twitch/colors.test.ts
Original file line number Diff line number Diff line change
@@ -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("#", "");
Expand Down Expand Up @@ -88,6 +92,35 @@ describe("readableUserColor", () => {
});
});

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_COLORS) {
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_COLORS) {
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");
Expand Down
41 changes: 40 additions & 1 deletion apps/web/src/lib/twitch/colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,14 +118,53 @@ 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;

// 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 {
const rgb = parseHex(color);
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;
}
// 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);
}

export function fallbackColor(login: string): string {
Expand Down
Loading