Skip to content

Commit 449a177

Browse files
authored
feat: adaptive background detection for dark mode support (#23)
* feat: adaptive background detection for dark mode support Replace hardcoded white (255,255,255) background assumption in content detection with auto-detection from image perimeter pixels. Two strategies, chosen automatically: - Alpha-only: transparent PNGs skip color-distance filtering entirely - Perimeter sampling: opaque images infer background via quantized color bucketing of edge pixels Adds backgroundColor prop as explicit override fallback. * fix: revert corner-first optimization, restore full perimeter scan The corner-first fast path misidentified logo content as background when logos extended to the corners, causing sizing regressions. Restores the first commit's logic (full perimeter scan, alphaOnly for transparent images) with typed arrays instead of Map for bucketing. * feat: add dark mode stories, test logos, shared story controls - Inverted SVGs (transparent, light content) and JPG test logos - Dark Mode, JPG, and Comparison stories under LogoSoup group - Shared StoryLogoSoup wrapper with debug controls - Consolidated argTypes, defaults, and types in shared.tsx - alphaOnly path for transparent images, color distance for opaque - min(a, sqrt(distSq)) opacity for opaque density normalization * fix: story sidebar ordering by renaming DarkMode story file * feat: accept CSS color strings for backgroundColor prop BackgroundColor type accepts hex, rgb(), hsl(), named colors, or [r, g, b] tuples. CSS strings are resolved via a 1×1 canvas.
1 parent 1b88111 commit 449a177

File tree

139 files changed

+1621
-373
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

139 files changed

+1621
-373
lines changed

.storybook/preview.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@ const preview: Preview = {
99
},
1010
},
1111
layout: "centered",
12+
options: {
13+
storySort: {
14+
order: [
15+
"LogoSoup",
16+
["Default", "Dark Mode", "JPG", "Comparison"],
17+
"No Optimization (Worst Case)",
18+
],
19+
},
20+
},
1221
},
1322
};
1423

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"scripts": {
2121
"build": "bun build src/index.ts --outdir dist --target browser --format esm --sourcemap=external --external react --external react-dom && bun build src/index.ts --outfile dist/index.cjs --target browser --format cjs --sourcemap=inline --external react --external react-dom && bun run build:types",
2222
"build:types": "tsc --declaration --emitDeclarationOnly --outDir dist",
23+
"generate:logos": "bun scripts/generate-test-logos.ts",
2324
"prepublishOnly": "bun run build",
2425
"test": "bun test",
2526
"bench": "bun tests/bench/run.ts",

scripts/generate-test-logos.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2+
import { join } from "node:path";
3+
import { createCanvas, loadImage } from "@napi-rs/canvas";
4+
5+
const LOGOS_DIR = join(import.meta.dir, "../static/logos");
6+
const INVERTED_DIR = join(import.meta.dir, "../static/logos-inverted");
7+
const JPG_DIR = join(import.meta.dir, "../static/logos-jpg");
8+
9+
mkdirSync(INVERTED_DIR, { recursive: true });
10+
mkdirSync(JPG_DIR, { recursive: true });
11+
12+
const svgFiles = readdirSync(LOGOS_DIR).filter((f) => f.endsWith(".svg"));
13+
14+
console.log(`Processing ${svgFiles.length} SVGs…\n`);
15+
16+
let invertedCount = 0;
17+
let jpgCount = 0;
18+
19+
for (const file of svgFiles) {
20+
const src = readFileSync(join(LOGOS_DIR, file), "utf-8");
21+
22+
// --- Inverted SVG: light content, still transparent ---
23+
const inverted = src.replaceAll('fill="#0B0B0B"', 'fill="#F4F4F4"');
24+
writeFileSync(join(INVERTED_DIR, file), inverted);
25+
invertedCount++;
26+
27+
// --- JPG: rasterize SVG onto opaque white background ---
28+
const svgBuf = Buffer.from(src);
29+
try {
30+
const img = await loadImage(svgBuf);
31+
const scale = Math.min(1, 400 / img.width);
32+
const w = Math.round(img.width * scale);
33+
const h = Math.round(img.height * scale);
34+
const canvas = createCanvas(w, h);
35+
const ctx = canvas.getContext("2d");
36+
ctx.fillStyle = "#ffffff";
37+
ctx.fillRect(0, 0, w, h);
38+
ctx.drawImage(img, 0, 0, w, h);
39+
const jpgBuf = canvas.toBuffer("image/jpeg");
40+
writeFileSync(join(JPG_DIR, file.replace(/\.svg$/, ".jpg")), jpgBuf);
41+
jpgCount++;
42+
} catch (e) {
43+
console.warn(` ⚠ Failed to rasterize ${file}: ${e}`);
44+
}
45+
}
46+
47+
console.log(`✓ ${invertedCount} inverted SVGs → static/logos-inverted/`);
48+
console.log(`✓ ${jpgCount} JPGs → static/logos-jpg/`);

src/components/LogoSoup.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export function LogoSoup({
2727
densityAware,
2828
densityFactor,
2929
cropToContent,
30+
backgroundColor,
3031
alignBy = DEFAULT_ALIGN_BY,
3132
gap = DEFAULT_GAP,
3233
renderImage,
@@ -42,6 +43,7 @@ export function LogoSoup({
4243
densityAware,
4344
densityFactor,
4445
cropToContent,
46+
backgroundColor,
4547
});
4648

4749
const ImageComponent = renderImage || DefaultImage;

src/hooks/useLogoSoup.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useReducer, useRef } from "react";
1+
import { useEffect, useMemo, useReducer, useRef } from "react";
22
import {
33
DEFAULT_BASE_SIZE,
44
DEFAULT_CONTRAST_THRESHOLD,
@@ -18,6 +18,7 @@ import {
1818
cropToBlobUrl,
1919
loadImage,
2020
measureWithContentDetection,
21+
resolveBackgroundColor,
2122
} from "../utils/measure";
2223
import {
2324
createNormalizedLogo,
@@ -91,8 +92,17 @@ export function useLogoSoup(options: UseLogoSoupOptions): UseLogoSoupResult {
9192
densityAware = DEFAULT_DENSITY_AWARE,
9293
densityFactor = DEFAULT_DENSITY_FACTOR,
9394
cropToContent = DEFAULT_CROP_TO_CONTENT,
95+
backgroundColor: backgroundColorProp,
9496
} = options;
9597

98+
const resolvedBg = useMemo(
99+
() =>
100+
backgroundColorProp
101+
? resolveBackgroundColor(backgroundColorProp)
102+
: undefined,
103+
[backgroundColorProp],
104+
);
105+
96106
const [state, dispatch] = useReducer(reducer, INITIAL_STATE);
97107

98108
const logosRef = useRef(logos);
@@ -105,6 +115,7 @@ export function useLogoSoup(options: UseLogoSoupOptions): UseLogoSoupResult {
105115
const cacheKeyRef = useRef({
106116
contrastThreshold: NaN,
107117
densityAware: false,
118+
resolvedBg: undefined as [number, number, number] | undefined,
108119
});
109120

110121
useEffect(() => {
@@ -120,12 +131,22 @@ export function useLogoSoup(options: UseLogoSoupOptions): UseLogoSoupResult {
120131
const cache = cacheRef.current;
121132
const prevKey = cacheKeyRef.current;
122133

134+
const bgChanged =
135+
prevKey.resolvedBg?.[0] !== resolvedBg?.[0] ||
136+
prevKey.resolvedBg?.[1] !== resolvedBg?.[1] ||
137+
prevKey.resolvedBg?.[2] !== resolvedBg?.[2];
138+
123139
if (
124140
prevKey.contrastThreshold !== contrastThreshold ||
125-
prevKey.densityAware !== densityAware
141+
prevKey.densityAware !== densityAware ||
142+
bgChanged
126143
) {
127144
clearCache(cache);
128-
cacheKeyRef.current = { contrastThreshold, densityAware };
145+
cacheKeyRef.current = {
146+
contrastThreshold,
147+
densityAware,
148+
resolvedBg,
149+
};
129150
}
130151

131152
const sources: LogoSource[] = stableLogos.map(normalizeSource);
@@ -178,6 +199,7 @@ export function useLogoSoup(options: UseLogoSoupOptions): UseLogoSoupResult {
178199
img,
179200
contrastThreshold,
180201
densityAware,
202+
resolvedBg,
181203
);
182204
entry = { img, measurement };
183205
cache.set(source.src, entry);
@@ -244,6 +266,7 @@ export function useLogoSoup(options: UseLogoSoupOptions): UseLogoSoupResult {
244266
densityAware,
245267
densityFactor,
246268
cropToContent,
269+
resolvedBg,
247270
]);
248271

249272
return {

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export { DEFAULT_ALIGN_BY } from "./constants";
33
export { useLogoSoup } from "./hooks/useLogoSoup";
44
export type {
55
AlignmentMode,
6+
BackgroundColor,
67
BoundingBox,
78
ImageRenderProps,
89
LogoSoupProps,

src/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import type { CSSProperties, ImgHTMLAttributes, ReactNode } from "react";
22

3+
type HexColor = `#${string}`;
4+
type RGBFunction = `rgb(${string})` | `rgba(${string})`;
5+
type HSLFunction = `hsl(${string})` | `hsla(${string})`;
6+
type CSSColor = HexColor | RGBFunction | HSLFunction | (string & {});
7+
8+
export type BackgroundColor = CSSColor | [number, number, number];
9+
310
export type AlignmentMode =
411
| "bounds"
512
| "visual-center"
@@ -65,6 +72,7 @@ export interface UseLogoSoupOptions {
6572
densityAware?: boolean;
6673
densityFactor?: number;
6774
cropToContent?: boolean;
75+
backgroundColor?: BackgroundColor;
6876
}
6977

7078
export interface UseLogoSoupResult {
@@ -82,6 +90,7 @@ export interface LogoSoupProps {
8290
densityAware?: boolean;
8391
densityFactor?: number;
8492
cropToContent?: boolean;
93+
backgroundColor?: BackgroundColor;
8594
alignBy?: AlignmentMode;
8695
gap?: number | string;
8796
renderImage?: RenderImageFn;

0 commit comments

Comments
 (0)