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
96 changes: 96 additions & 0 deletions desktop/src/shared/theme/ThemeProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
12 changes: 10 additions & 2 deletions desktop/src/shared/ui/VideoPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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)}
Expand Down
Loading