mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Use accent color for video timecodes (#1167)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user