From d33117f8b2c6fb7d3a84617d9102079ede85aed8 Mon Sep 17 00:00:00 2001 From: ImpulseB23 Date: Sun, 19 Apr 2026 05:02:19 +0200 Subject: [PATCH 1/2] feat(chat): timestamps and readable username colors Prefix each message with a 24h HH:MM timestamp (local time, fed into Pretext so wrapping stays exact) and lift low-luminance user colors above a WCAG threshold so deep blues/blacks stay readable on the dark background. --- apps/desktop/src/components/ChatFeed.tsx | 15 +++- apps/desktop/src/lib/messageLayout.ts | 21 +++++- apps/desktop/src/lib/messageStyle.test.ts | 46 ++++++++++++ apps/desktop/src/lib/messageStyle.ts | 89 +++++++++++++++++++++++ 4 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/lib/messageStyle.test.ts create mode 100644 apps/desktop/src/lib/messageStyle.ts diff --git a/apps/desktop/src/components/ChatFeed.tsx b/apps/desktop/src/components/ChatFeed.tsx index 5a5f684..6fd8cf6 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,17 @@ 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}`; +} From dc5c56525b3fdebaec7884f0ed09e07bf4fe4b15 Mon Sep 17 00:00:00 2001 From: ImpulseB23 Date: Sun, 19 Apr 2026 05:11:38 +0200 Subject: [PATCH 2/2] fix(chat): keep timestamp DOM in lockstep with Pretext measurement Store the trailing space inside PreparedMessage.timestamp and render it verbatim instead of using margin-right, so Pretext's measured prefix width matches the DOM exactly. --- apps/desktop/src/components/ChatFeed.tsx | 3 +-- apps/desktop/src/lib/messageLayout.ts | 8 +++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/components/ChatFeed.tsx b/apps/desktop/src/components/ChatFeed.tsx index 6fd8cf6..d27ec14 100644 --- a/apps/desktop/src/components/ChatFeed.tsx +++ b/apps/desktop/src/components/ChatFeed.tsx @@ -312,8 +312,7 @@ const ChatFeed: Component = () => { {item.prepared.timestamp} diff --git a/apps/desktop/src/lib/messageLayout.ts b/apps/desktop/src/lib/messageLayout.ts index cef8ad2..6a6e5cf 100644 --- a/apps/desktop/src/lib/messageLayout.ts +++ b/apps/desktop/src/lib/messageLayout.ts @@ -89,11 +89,13 @@ export function prepareMessage( // Timestamp prefix: a single non-breaking text run so the username // never wraps onto a line by itself when the timestamp pushes the // first badge down. The trailing space keeps separation from badges - // without needing a separate item. - const timestamp = formatTimestamp(msg.timestamp); + // without needing a separate item, and we expose the exact same + // string to the DOM so Pretext's measurement matches what renders. + const formatted = formatTimestamp(msg.timestamp); + const timestamp = formatted ? `${formatted} ` : ""; if (timestamp) { items.push({ - text: `${timestamp} `, + text: timestamp, font: TIMESTAMP_FONT, break: "never", });