diff --git a/apps/desktop/src/components/ChatFeed.tsx b/apps/desktop/src/components/ChatFeed.tsx index 5a5f684..d27ec14 100644 --- a/apps/desktop/src/components/ChatFeed.tsx +++ b/apps/desktop/src/components/ChatFeed.tsx @@ -7,6 +7,7 @@ import { Component, For, + Show, createEffect, createMemo, createSignal, @@ -38,6 +39,7 @@ import { prepareMessage, type PreparedMessage, } from "../lib/messageLayout"; +import { normalizeUserColor } from "../lib/messageStyle"; import type { MessagePiece } from "../lib/emoteSpans"; const OVERSCAN = 6; @@ -306,6 +308,16 @@ const ChatFeed: Component = () => { "overflow-wrap": "break-word", }} > + + + {item.prepared.timestamp} + + {(b) => ( { { + it("returns the fallback for null/empty/invalid", () => { + expect(normalizeUserColor(null)).toBe("#9147ff"); + expect(normalizeUserColor("")).toBe("#9147ff"); + expect(normalizeUserColor("not-a-color")).toBe("#9147ff"); + expect(normalizeUserColor("#xyz")).toBe("#9147ff"); + }); + + it("passes through readable colors unchanged", () => { + expect(normalizeUserColor("#ff7f50")).toBe("#ff7f50"); + expect(normalizeUserColor("#ffffff")).toBe("#ffffff"); + }); + + it("expands 3-digit hex", () => { + expect(normalizeUserColor("#fc8")).toBe("#ffcc88"); + }); + + it("lifts pitch black to a visible grey", () => { + const out = normalizeUserColor("#000000"); + expect(out).not.toBe("#000000"); + // Shouldn't blow out to white either; just enough to read. + expect(out).not.toBe("#ffffff"); + }); + + it("lifts deep blue to a readable blue", () => { + const out = normalizeUserColor("#0000ff"); + // Channel zero gets lifted, so blue stays dominant but red/green grow. + expect(out).toMatch(/^#[0-9a-f]{6}$/); + expect(out).not.toBe("#0000ff"); + }); +}); + +describe("formatTimestamp", () => { + it("zero-pads hours and minutes", () => { + // 2026-01-01T03:07:00 local time + const d = new Date(2026, 0, 1, 3, 7, 0).getTime(); + expect(formatTimestamp(d)).toBe("03:07"); + }); + + it("returns empty string for NaN", () => { + expect(formatTimestamp(Number.NaN)).toBe(""); + }); +}); diff --git a/apps/desktop/src/lib/messageStyle.ts b/apps/desktop/src/lib/messageStyle.ts new file mode 100644 index 0000000..39f78d4 --- /dev/null +++ b/apps/desktop/src/lib/messageStyle.ts @@ -0,0 +1,89 @@ +// Username color normalization for chat. Twitch (and YouTube/Kick) lets +// users pick any hex color; many of those are unreadable on the app's +// dark background (e.g. #0000FF, #2E0000). We boost luminance until the +// color clears a minimum readable threshold against #0e0e10, matching +// the behavior the official Twitch web client uses for "Adjust Colors". + +const FALLBACK = "#9147ff"; + +// WCAG-style relative luminance threshold. 0.20 keeps deep colors moody +// but readable; anything lower (#000080 etc.) gets lifted toward white. +const MIN_LUMINANCE = 0.2; + +interface Rgb { + r: number; + g: number; + b: number; +} + +function parseHex(input: string): Rgb | null { + let s = input.trim(); + if (s.startsWith("#")) s = s.slice(1); + if (s.length === 3) { + s = s + .split("") + .map((c) => c + c) + .join(""); + } + if (s.length !== 6 || !/^[0-9a-fA-F]{6}$/.test(s)) return null; + return { + r: parseInt(s.slice(0, 2), 16), + g: parseInt(s.slice(2, 4), 16), + b: parseInt(s.slice(4, 6), 16), + }; +} + +function srgbToLinear(c: number): number { + const v = c / 255; + return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); +} + +function relativeLuminance({ r, g, b }: Rgb): number { + return ( + 0.2126 * srgbToLinear(r) + + 0.7152 * srgbToLinear(g) + + 0.0722 * srgbToLinear(b) + ); +} + +function toHex({ r, g, b }: Rgb): string { + const h = (n: number) => Math.round(n).toString(16).padStart(2, "0"); + return `#${h(r)}${h(g)}${h(b)}`; +} + +// Iteratively lift each channel toward white until the color clears the +// minimum luminance. A pure ratio scale would still produce black for +// black inputs, so we add a constant floor before scaling. +function liftToReadable(rgb: Rgb): Rgb { + let { r, g, b } = rgb; + // Seed pitch-black with a small grey so the loop has something to scale. + if (r + g + b < 24) { + r = g = b = 24; + } + for (let i = 0; i < 16; i++) { + if (relativeLuminance({ r, g, b }) >= MIN_LUMINANCE) break; + r = Math.min(255, r + (255 - r) * 0.2 + 8); + g = Math.min(255, g + (255 - g) * 0.2 + 8); + b = Math.min(255, b + (255 - b) * 0.2 + 8); + } + return { r, g, b }; +} + +export function normalizeUserColor(color: string | null | undefined): string { + if (!color) return FALLBACK; + const rgb = parseHex(color); + if (!rgb) return FALLBACK; + if (relativeLuminance(rgb) >= MIN_LUMINANCE) return toHex(rgb); + return toHex(liftToReadable(rgb)); +} + +// Format a Unix-millis timestamp as a short 24-hour HH:MM string in the +// user's local timezone. Stable across renders for the same input so the +// virtual scroller doesn't churn. +export function formatTimestamp(unixMillis: number): string { + const d = new Date(unixMillis); + if (Number.isNaN(d.getTime())) return ""; + const hh = d.getHours().toString().padStart(2, "0"); + const mm = d.getMinutes().toString().padStart(2, "0"); + return `${hh}:${mm}`; +}