Use accent color for video timecodes (#1167)

This commit is contained in:
klopez4212
2026-06-22 09:27:36 -07:00
committed by GitHub
parent f218b8158e
commit c243a01362
3 changed files with 284 additions and 62 deletions
@@ -19,6 +19,10 @@ export const THEME_STORAGE_KEY = "buzz-theme";
const CACHE_KEY = "buzz-theme-cache";
export const ACCENT_STORAGE_KEY = "buzz-accent-color";
export const NEUTRAL_ACCENT = "neutral";
const VIDEO_REVIEW_NEUTRAL_ACCENT = "0 0% 98%";
const VIDEO_REVIEW_CHIP_SURFACE = "#161616";
const VIDEO_REVIEW_TEXT_CONTRAST = 4.5;
const VIDEO_REVIEW_CHIP_BACKGROUND_ALPHAS = [0.15, 0.3] as const;
export const ACCENT_COLORS = [
{ name: "Neutral", value: NEUTRAL_ACCENT },
@@ -77,12 +81,98 @@ function getContrastColor(hex: string): string {
return lum > 0.5 ? "#000000" : "#ffffff";
}
type Rgb = {
r: number;
g: number;
b: number;
};
function hexToRgb(hex: string): Rgb {
const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})/i.exec(hex);
if (!m) return { r: 255, g: 255, b: 255 };
return {
r: parseInt(m[1], 16),
g: parseInt(m[2], 16),
b: parseInt(m[3], 16),
};
}
function mixRgb(from: Rgb, to: Rgb, factor: number): Rgb {
return {
r: from.r + (to.r - from.r) * factor,
g: from.g + (to.g - from.g) * factor,
b: from.b + (to.b - from.b) * factor,
};
}
function compositeRgb(foreground: Rgb, background: Rgb, alpha: number): Rgb {
return mixRgb(background, foreground, alpha);
}
function relativeLuminance({ r, g, b }: Rgb): number {
const [rs, gs, bs] = [r, g, b].map((channel) => {
const value = channel / 255;
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}
function contrastRatio(a: Rgb, b: Rgb): number {
const aLum = relativeLuminance(a);
const bLum = relativeLuminance(b);
return (Math.max(aLum, bLum) + 0.05) / (Math.min(aLum, bLum) + 0.05);
}
function getReviewAccentForeground(hex: string): string {
const accent = hexToRgb(hex);
const surface = hexToRgb(VIDEO_REVIEW_CHIP_SURFACE);
const white = { r: 255, g: 255, b: 255 };
const backgrounds = VIDEO_REVIEW_CHIP_BACKGROUND_ALPHAS.map((alpha) =>
compositeRgb(accent, surface, alpha),
);
let low = 0;
let high = 1;
for (let i = 0; i < 20; i++) {
const mid = (low + high) / 2;
const candidate = mixRgb(accent, white, mid);
const minContrast = Math.min(
...backgrounds.map((background) => contrastRatio(candidate, background)),
);
if (minContrast >= VIDEO_REVIEW_TEXT_CONTRAST) {
high = mid;
} else {
low = mid;
}
}
return hexToHsl(rgbToHex(mixRgb(accent, white, high)));
}
function rgbToHex({ r, g, b }: Rgb): string {
const clamp = (value: number) =>
Math.max(0, Math.min(255, Math.round(value)));
return `#${[r, g, b]
.map((channel) => clamp(channel).toString(16).padStart(2, "0"))
.join("")}`;
}
function applyAccentColor(value: string) {
const root = document.documentElement;
if (value === NEUTRAL_ACCENT) {
const styles = window.getComputedStyle(root);
const foreground = styles.getPropertyValue("--foreground").trim();
const background = styles.getPropertyValue("--background").trim();
root.style.setProperty("--buzz-selected-accent", foreground);
root.style.setProperty(
"--buzz-video-review-accent",
VIDEO_REVIEW_NEUTRAL_ACCENT,
);
root.style.setProperty(
"--buzz-video-review-accent-foreground",
VIDEO_REVIEW_NEUTRAL_ACCENT,
);
root.style.setProperty("--primary", foreground);
root.style.setProperty("--primary-foreground", background);
root.style.setProperty("--sidebar-primary", foreground);
@@ -95,6 +185,12 @@ function applyAccentColor(value: string) {
const hex = value;
const accentHsl = hexToHsl(hex);
const fgHsl = hexToHsl(getContrastColor(hex));
root.style.setProperty("--buzz-selected-accent", accentHsl);
root.style.setProperty("--buzz-video-review-accent", accentHsl);
root.style.setProperty(
"--buzz-video-review-accent-foreground",
getReviewAccentForeground(hex),
);
root.style.setProperty("--primary", accentHsl);
root.style.setProperty("--primary-foreground", fgHsl);
root.style.setProperty("--sidebar-primary", accentHsl);
+10 -2
View File
@@ -106,6 +106,10 @@ const QUICK_REACTIONS = ["😂", "😍", "😮", "🙌", "👍", "👎"];
const DEFAULT_PLAYBACK_SPEED = 1;
const INLINE_SPEED_CONTROL_MIN_WIDTH = 220;
const PLAYBACK_SPEEDS = [2, 1.75, 1.5, 1.25, 1, 0.75, 0.5, 0.25];
const TIMECODE_ACCENT_CLASS =
"bg-[hsl(var(--buzz-video-review-accent,var(--primary))/0.15)] text-[hsl(var(--buzz-video-review-accent-foreground,var(--buzz-video-review-accent,var(--primary))))]";
const TIMECODE_ACCENT_HOVER_CLASS =
"hover:bg-[hsl(var(--buzz-video-review-accent,var(--primary))/0.3)]";
/**
* Frosted-glass backing layer for floating media controls. The parent must
@@ -1941,7 +1945,7 @@ function VideoReviewDialog({
className={cn(
"rounded-md px-2 py-1 font-mono text-xs font-semibold transition-colors",
!replyTarget && postAtCurrentFrame
? "bg-amber-400/15 text-amber-300"
? TIMECODE_ACCENT_CLASS
: "bg-muted text-muted-foreground/70",
)}
data-testid="video-review-composer-timecode"
@@ -2107,7 +2111,11 @@ function VideoReviewCommentBody({
item.seconds !== null && item.timecode ? (
<button
aria-label={`Jump to ${item.timecode}`}
className="inline-flex h-5 shrink-0 items-center rounded bg-amber-400/15 px-1.5 align-middle font-mono text-2xs font-semibold text-amber-300 outline-hidden transition-colors hover:bg-amber-400/30 focus-visible:ring-2 focus-visible:ring-white/60"
className={cn(
"inline-flex h-5 shrink-0 items-center rounded px-1.5 align-middle font-mono text-2xs font-semibold outline-hidden transition-colors focus-visible:ring-2 focus-visible:ring-white/60",
TIMECODE_ACCENT_CLASS,
TIMECODE_ACCENT_HOVER_CLASS,
)}
data-testid="video-review-comment-timecode"
type="button"
onClick={() => onSeek(item.seconds ?? 0)}
+178 -60
View File
@@ -8,6 +8,13 @@ const PORTRAIT_VIDEO_SHA = "c".repeat(64);
const PORTRAIT_VIDEO_URL = `http://localhost:3000/media/${PORTRAIT_VIDEO_SHA}.mp4`;
const CONSTRAINED_LANDSCAPE_VIDEO_SHA = "d".repeat(64);
const CONSTRAINED_LANDSCAPE_VIDEO_URL = `http://localhost:3000/media/${CONSTRAINED_LANDSCAPE_VIDEO_SHA}.mp4`;
const VIDEO_REVIEW_NEUTRAL_ACCENT = "neutral";
const VIDEO_REVIEW_LIGHT_THEME = "catppuccin-latte";
const VIDEO_REVIEW_ACCENT = "#ec4899";
const VIDEO_REVIEW_ACCENT_FOREGROUND_RGB = "rgb(240, 115, 177)";
const VIDEO_REVIEW_INDIGO_ACCENT = "#6366f1";
const VIDEO_REVIEW_INDIGO_FOREGROUND_RGB = "rgb(141, 143, 245)";
const VIDEO_REVIEW_NEUTRAL_DARK_RGB = "rgb(250, 250, 250)";
const POSTER_DATA_URL =
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNjAgODAiPjxyZWN0IHdpZHRoPSIxNjAiIGhlaWdodD0iODAiIGZpbGw9IiMyNjQ2NTMiLz48Y2lyY2xlIGN4PSI1NCIgY3k9IjQwIiByPSIyMiIgZmlsbD0iI2YyYzE0ZSIvPjxwYXRoIGQ9Ik05MiAyNGg0NHYzMkg5MnoiIGZpbGw9IiNmNzgxNTQiLz48L3N2Zz4=";
@@ -55,67 +62,81 @@ function emitMockMessage(
);
}
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
type MediaState = {
currentTime: number;
paused: boolean;
};
const mediaState = new WeakMap<HTMLMediaElement, MediaState>();
const getMediaState = (element: HTMLMediaElement) => {
let state = mediaState.get(element);
if (!state) {
state = { currentTime: 0, paused: true };
mediaState.set(element, state);
async function installVideoReviewHarness(
page: Page,
{
accentColor = VIDEO_REVIEW_ACCENT,
themeName,
}: { accentColor?: string; themeName?: string } = {},
) {
await page.addInitScript(
({ accentColor, themeName }) => {
if (themeName) {
window.localStorage.setItem("buzz-theme", themeName);
}
return state;
};
window.localStorage.setItem("buzz-accent-color", accentColor);
Object.defineProperty(HTMLMediaElement.prototype, "load", {
configurable: true,
value() {
getMediaState(this as HTMLMediaElement).currentTime = 0;
},
});
Object.defineProperty(HTMLMediaElement.prototype, "play", {
configurable: true,
value() {
getMediaState(this as HTMLMediaElement).paused = false;
this.dispatchEvent(new Event("play"));
return Promise.resolve();
},
});
Object.defineProperty(HTMLMediaElement.prototype, "pause", {
configurable: true,
value() {
getMediaState(this as HTMLMediaElement).paused = true;
this.dispatchEvent(new Event("pause"));
},
});
Object.defineProperty(HTMLMediaElement.prototype, "paused", {
configurable: true,
get() {
return getMediaState(this as HTMLMediaElement).paused;
},
});
Object.defineProperty(HTMLMediaElement.prototype, "currentTime", {
configurable: true,
get() {
return getMediaState(this as HTMLMediaElement).currentTime;
},
set(value) {
getMediaState(this as HTMLMediaElement).currentTime =
Number(value) || 0;
this.dispatchEvent(new Event("seeked"));
},
});
Object.defineProperty(HTMLMediaElement.prototype, "duration", {
configurable: true,
get() {
return 12.5;
},
});
});
type MediaState = {
currentTime: number;
paused: boolean;
};
const mediaState = new WeakMap<HTMLMediaElement, MediaState>();
const getMediaState = (element: HTMLMediaElement) => {
let state = mediaState.get(element);
if (!state) {
state = { currentTime: 0, paused: true };
mediaState.set(element, state);
}
return state;
};
Object.defineProperty(HTMLMediaElement.prototype, "load", {
configurable: true,
value() {
getMediaState(this as HTMLMediaElement).currentTime = 0;
},
});
Object.defineProperty(HTMLMediaElement.prototype, "play", {
configurable: true,
value() {
getMediaState(this as HTMLMediaElement).paused = false;
this.dispatchEvent(new Event("play"));
return Promise.resolve();
},
});
Object.defineProperty(HTMLMediaElement.prototype, "pause", {
configurable: true,
value() {
getMediaState(this as HTMLMediaElement).paused = true;
this.dispatchEvent(new Event("pause"));
},
});
Object.defineProperty(HTMLMediaElement.prototype, "paused", {
configurable: true,
get() {
return getMediaState(this as HTMLMediaElement).paused;
},
});
Object.defineProperty(HTMLMediaElement.prototype, "currentTime", {
configurable: true,
get() {
return getMediaState(this as HTMLMediaElement).currentTime;
},
set(value) {
getMediaState(this as HTMLMediaElement).currentTime =
Number(value) || 0;
this.dispatchEvent(new Event("seeked"));
},
});
Object.defineProperty(HTMLMediaElement.prototype, "duration", {
configurable: true,
get() {
return 12.5;
},
});
},
{ accentColor, themeName },
);
await installMockBridge(page, {
uploadDescriptors: [
@@ -132,11 +153,65 @@ test.beforeEach(async ({ page }) => {
},
],
});
});
}
async function openReviewWithPostedTimecode(
page: Page,
commentText = "Neutral accent check",
) {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
await page.getByRole("button", { name: "Attach image" }).click();
await expect(
page.getByTestId("message-composer").getByAltText("Video attachment bbbb"),
).toBeVisible();
await page.getByTestId("send-message").click();
await expect(page.getByText("Sending")).toHaveCount(0);
const reviewButton = page
.getByRole("button", { name: "Open video review" })
.last();
await expect(reviewButton).toBeVisible();
await page.waitForFunction(() => {
const launcher = document.querySelector("[data-video-review-launcher]");
const row = launcher?.closest("[data-message-id]");
const messageId = row?.getAttribute("data-message-id") ?? "";
return Boolean(messageId) && !messageId.startsWith("optimistic");
});
await reviewButton.evaluate((button) =>
(button as HTMLButtonElement).click(),
);
const reviewDialog = page.getByTestId("video-review-dialog");
await expect(reviewDialog).toBeVisible();
await reviewDialog.locator("video").evaluate((video) => {
const el = video as HTMLVideoElement;
el.currentTime = 10;
el.dispatchEvent(new Event("timeupdate"));
});
await expect(page.getByTestId("video-review-composer-timecode")).toHaveText(
"00:10",
);
const commentBox = reviewDialog.getByTestId("message-input");
await commentBox.click();
await commentBox.fill(commentText);
await reviewDialog.getByTestId("send-message").click();
await expect(page.getByTestId("video-review-comments")).toContainText(
commentText,
);
return reviewDialog;
}
test("video upload previews use poster frames and inline videos open review mode", async ({
page,
}) => {
await installVideoReviewHarness(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
@@ -517,6 +592,9 @@ test("video upload previews use poster frames and inline videos open review mode
await expect(
reviewDialog.getByTestId("video-review-comment-timecode").first(),
).toHaveText("00:10");
await expect(
reviewDialog.getByTestId("video-review-comment-timecode").first(),
).toHaveCSS("color", VIDEO_REVIEW_ACCENT_FOREGROUND_RGB);
// Regression: a timeline re-render (live message arriving in the channel)
// must not remount the review dialog or wipe an in-progress comment draft.
@@ -670,6 +748,8 @@ test("video upload previews use poster frames and inline videos open review mode
});
test("narrow inline videos hide playback speed control", async ({ page }) => {
await installVideoReviewHarness(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
@@ -713,6 +793,8 @@ test("narrow inline videos hide playback speed control", async ({ page }) => {
test("constrained landscape inline videos measure rendered width before showing speed", async ({
page,
}) => {
await installVideoReviewHarness(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
@@ -763,3 +845,39 @@ test("constrained landscape inline videos measure rendered width before showing
0,
);
});
test("neutral accent uses the forced-dark review foreground", async ({
page,
}) => {
await installVideoReviewHarness(page, {
accentColor: VIDEO_REVIEW_NEUTRAL_ACCENT,
themeName: VIDEO_REVIEW_LIGHT_THEME,
});
const reviewDialog = await openReviewWithPostedTimecode(page);
await expect(
reviewDialog.getByTestId("video-review-composer-timecode"),
).toHaveCSS("color", VIDEO_REVIEW_NEUTRAL_DARK_RGB);
await expect(
reviewDialog.getByTestId("video-review-comment-timecode").first(),
).toHaveCSS("color", VIDEO_REVIEW_NEUTRAL_DARK_RGB);
});
test("dark accent uses a contrast-safe review foreground", async ({ page }) => {
await installVideoReviewHarness(page, {
accentColor: VIDEO_REVIEW_INDIGO_ACCENT,
});
const reviewDialog = await openReviewWithPostedTimecode(
page,
"Indigo contrast check",
);
await expect(
reviewDialog.getByTestId("video-review-composer-timecode"),
).toHaveCSS("color", VIDEO_REVIEW_INDIGO_FOREGROUND_RGB);
await expect(
reviewDialog.getByTestId("video-review-comment-timecode").first(),
).toHaveCSS("color", VIDEO_REVIEW_INDIGO_FOREGROUND_RGB);
});