Pin the review card description under the sections and tint the card from the uploaded logo.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
adilalloandCursor committed 2026-09-28 15:46:45 -06:00
1 parent 2653ae4602
commit 56adcd59fd
10 files changed
+508 -42

No files matched your search

+181
View File
@@ -0,0 +1,181 @@
const QUANTIZE_SHIFT = 4;
/** Ignore only fully clear pixels so a translucent logo fill still counts. */
const MIN_ALPHA = 16;
/** Black card type needs a light fill. Dark logo colors are lifted to this. */
const CARD_FILL_LIGHTNESS = 0.82;
const CARD_FILL_MAX_SATURATION = 0.48;
function rgbToHsl(
r: number,
g: number,
b: number,
): { h: number; s: number; l: number } {
const rn = r / 255;
const gn = g / 255;
const bn = b / 255;
const max = Math.max(rn, gn, bn);
const min = Math.min(rn, gn, bn);
const l = (max + min) / 2;
if (max === min) return { h: 0, s: 0, l };
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h = 0;
if (max === rn) h = (gn - bn) / d + (gn < bn ? 6 : 0);
else if (max === gn) h = (bn - rn) / d + 2;
else h = (rn - gn) / d + 4;
return { h: h / 6, s, l };
}
function hueChannel(p: number, q: number, t: number): number {
let channel = t;
if (channel < 0) channel += 1;
if (channel > 1) channel -= 1;
if (channel < 1 / 6) return p + (q - p) * 6 * channel;
if (channel < 1 / 2) return q;
if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6;
return p;
}
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
if (s === 0) {
const value = Math.round(l * 255);
return [value, value, value];
}
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
return [
Math.round(hueChannel(p, q, h + 1 / 3) * 255),
Math.round(hueChannel(p, q, h) * 255),
Math.round(hueChannel(p, q, h - 1 / 3) * 255),
];
}
/**
* Keep an already-light logo color. Lift a dark one to a pale tint of the
* same hue so the card’s black type stays readable.
*/
export function readableCardFill(r: number, g: number, b: number): string {
const { h, s, l } = rgbToHsl(r, g, b);
if (l >= 0.7) return `rgb(${r}, ${g}, ${b})`;
const [rr, gg, bb] = hslToRgb(
h,
Math.min(s, CARD_FILL_MAX_SATURATION),
CARD_FILL_LIGHTNESS,
);
return `rgb(${rr}, ${gg}, ${bb})`;
}
/**
* Most common opaque color in `data` (RGBA). Nearby shades share a bucket
* so a logo’s main fill wins over anti-aliased edges. Returns `null` when
* every pixel is transparent.
*/
export function mostCommonOpaqueColor(data: Uint8ClampedArray): string | null {
const buckets = new Map<
number,
{ count: number; r: number; g: number; b: number }
>();
for (let i = 0; i < data.length; i += 4) {
const alpha = data[i + 3] ?? 0;
if (alpha < MIN_ALPHA) continue;
const r = data[i] ?? 0;
const g = data[i + 1] ?? 0;
const b = data[i + 2] ?? 0;
const key =
((r >> QUANTIZE_SHIFT) << 8) |
((g >> QUANTIZE_SHIFT) << 4) |
(b >> QUANTIZE_SHIFT);
const bucket = buckets.get(key);
if (bucket) {
bucket.count += 1;
bucket.r += r;
bucket.g += g;
bucket.b += b;
} else {
buckets.set(key, { count: 1, r, g, b });
}
}
let best: { count: number; r: number; g: number; b: number } | null = null;
for (const bucket of buckets.values()) {
if (!best || bucket.count > best.count) best = bucket;
}
if (!best || best.count === 0) return null;
const r = Math.round(best.r / best.count);
const g = Math.round(best.g / best.count);
const b = Math.round(best.b / best.count);
return `rgb(${r}, ${g}, ${b})`;
}
/**
* Light random fill. Hue is free; lightness stays high so the card’s black
* type remains readable.
*/
export function randomPreviewSurfaceColor(
random: () => number = Math.random,
): string {
const hue = Math.floor(random() * 360);
const saturation = 50 + Math.floor(random() * 35);
const lightness = 76 + Math.floor(random() * 12);
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
}
function imageNeedsCors(url: string): boolean {
if (
url.startsWith("/") ||
url.startsWith("blob:") ||
url.startsWith("data:")
) {
return false;
}
try {
return new URL(url, window.location.href).origin !== window.location.origin;
} catch {
return false;
}
}
function loadImage(url: string): Promise<HTMLImageElement | null> {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => resolve(null);
if (imageNeedsCors(url)) img.crossOrigin = "anonymous";
img.src = url;
});
}
/** Downscale `url` and return its most common opaque color, or `null`. */
export async function dominantColorFromImageUrl(
url: string,
): Promise<string | null> {
if (typeof window === "undefined") return null;
const img = await loadImage(url);
if (!img) return null;
const size = 32;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) return null;
context.drawImage(img, 0, 0, size, size);
try {
const raw = mostCommonOpaqueColor(
context.getImageData(0, 0, size, size).data,
);
if (!raw) return null;
const match = raw.match(/^rgb\((\d+), (\d+), (\d+)\)$/);
if (!match) return raw;
return readableCardFill(
Number(match[1]),
Number(match[2]),
Number(match[3]),
);
} catch {
return null;
}
}