Skip to content

Commit 434459b

Browse files
ralyodioclaude
andauthored
store: render listing logos on a luminance-picked light/dark tile (#42)
Listing logos are transparent PNGs but were shown with object-fit:cover over the gradient avatar, so the empty areas revealed the teal→blue gradient (muddy/purplish) and the logo got cropped. Render the logo contained (never cropped) in a padded, bordered tile and choose the backdrop from the logo's own opaque-pixel luminance: bright logos get a dark tile, dark logos a light tile (CORS-tainted/opaque-less icons fall back to dark). Initials avatar is unchanged for icon-less listings. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 398c7e7 commit 434459b

2 files changed

Lines changed: 56 additions & 3 deletions

File tree

apps/extensions/public/store.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,15 @@ input:focus, textarea:focus, select:focus { outline: none; border-color: var(--a
9292
font-weight: 800; color: #04211d; background: linear-gradient(135deg, var(--accent), var(--accent-2));
9393
flex: 0 0 auto;
9494
}
95+
/* Real logo (usually a transparent PNG): contain it in a padded, bordered tile
96+
and drop the gradient. The backdrop is chosen from the logo's luminance in
97+
store.js (hydrateIcons); a neutral panel shows until then. */
98+
img.avatar.icon {
99+
background: var(--panel); border: 1px solid var(--border);
100+
padding: 5px; object-fit: contain;
101+
}
102+
img.avatar.icon.icon-dark { background: #0b1018; }
103+
img.avatar.icon.icon-light { background: #ffffff; border-color: rgba(0, 0, 0, 0.12); }
95104
.card h3 { margin: 0; font-size: 16px; }
96105
.card .summary { color: var(--muted); font-size: 14px; margin: 0; min-height: 38px; }
97106
.card .meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: auto; }

apps/extensions/public/store.js

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,52 @@ function qs(name) { return new URLSearchParams(location.search).get(name); }
88
function initials(name) { return (name || '?').trim().split(/\s+/).slice(0, 2).map((w) => w[0] || '').join('').toUpperCase(); }
99

1010
// Logo if the listing has one (auto-ingested from the .crx icons), else initials.
11+
// Most logos are transparent PNGs, so we render them CONTAINED (never cropped)
12+
// and pick a light or dark backdrop from the logo's own luminance in
13+
// hydrateIcons() — otherwise a transparent logo shows over the gradient avatar
14+
// and looks muddy.
1115
function avatar(ext, px) {
12-
const style = px ? ` style="width:${px}px;height:${px}px"` : '';
13-
if (ext.iconUrl) return `<img class="avatar" src="${esc(ext.iconUrl)}" alt=""${style} style="object-fit:cover${px ? `;width:${px}px;height:${px}px` : ''}">`;
14-
return `<div class="avatar"${px ? ` style="width:${px}px;height:${px}px;font-size:20px"` : ''}>${esc(initials(ext.name))}</div>`;
16+
const dim = px ? `width:${px}px;height:${px}px` : '';
17+
if (ext.iconUrl) return `<img class="avatar icon" data-icon src="${esc(ext.iconUrl)}" alt="" style="object-fit:contain;${dim}">`;
18+
return `<div class="avatar"${px ? ` style="${dim};font-size:20px"` : ''}>${esc(initials(ext.name))}</div>`;
19+
}
20+
21+
// Choose 'dark' or 'light' backdrop for a (usually transparent) logo by the
22+
// average luminance of its opaque pixels: a bright logo wants a dark tile, a
23+
// dark logo a light tile. Returns null if unreadable (fully transparent / a
24+
// CORS-tainted canvas), leaving the default neutral tile.
25+
function backdropFor(img) {
26+
try {
27+
const s = 24;
28+
const c = document.createElement('canvas');
29+
c.width = s; c.height = s;
30+
const ctx = c.getContext('2d', { willReadFrequently: true });
31+
ctx.drawImage(img, 0, 0, s, s);
32+
const { data } = ctx.getImageData(0, 0, s, s);
33+
let lum = 0, wsum = 0;
34+
for (let i = 0; i < data.length; i += 4) {
35+
const a = data[i + 3] / 255;
36+
if (a < 0.1) continue;
37+
lum += (0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2]) * a;
38+
wsum += a;
39+
}
40+
if (wsum === 0) return null;
41+
return lum / wsum > 140 ? 'dark' : 'light';
42+
} catch (_) {
43+
return 'dark'; // tainted canvas (remote icon) — dark suits the store theme
44+
}
45+
}
46+
47+
function hydrateIcons(root) {
48+
root.querySelectorAll('img.avatar.icon[data-icon]').forEach((img) => {
49+
const apply = () => {
50+
const b = backdropFor(img);
51+
if (b) img.classList.add('icon-' + b);
52+
img.removeAttribute('data-icon');
53+
};
54+
if (img.complete && img.naturalWidth) apply();
55+
else img.addEventListener('load', apply, { once: true });
56+
});
1557
}
1658

1759
// ── AI auto-ingest: paste a .crx URL, we fill the form + scan (no forms) ──
@@ -115,6 +157,7 @@ async function initBrowse() {
115157
if (!extensions.length) { grid.innerHTML = ''; empty.classList.remove('hidden'); return; }
116158
empty.classList.add('hidden');
117159
grid.innerHTML = extensions.map(card).join('');
160+
hydrateIcons(grid);
118161
} catch (e) {
119162
grid.innerHTML = `<p class="error">Couldn't load extensions: ${esc(e.message)}</p>`;
120163
}
@@ -162,6 +205,7 @@ async function initDetail() {
162205
<h3>Auto-update</h3>
163206
<p class="hint">Chromium auto-updates this extension from its <code>update_url</code>:</p>
164207
<pre>${esc(ext.updateUrl)}</pre>`;
208+
hydrateIcons(root);
165209

166210
document.getElementById('flagBtn').addEventListener('click', async () => {
167211
const reason = prompt('Reason? (malware, privacy, broken, spam, other)', 'other');

0 commit comments

Comments
 (0)