From 8f3c335a01dae57a4fbbf69e380ce2479eb50171 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 7 Jul 2026 10:31:19 +0000 Subject: [PATCH] store: render listing logos on a luminance-picked light/dark tile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/extensions/public/store.css | 9 ++++++ apps/extensions/public/store.js | 50 ++++++++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/apps/extensions/public/store.css b/apps/extensions/public/store.css index edde84f..9c028ac 100644 --- a/apps/extensions/public/store.css +++ b/apps/extensions/public/store.css @@ -92,6 +92,15 @@ input:focus, textarea:focus, select:focus { outline: none; border-color: var(--a font-weight: 800; color: #04211d; background: linear-gradient(135deg, var(--accent), var(--accent-2)); flex: 0 0 auto; } +/* Real logo (usually a transparent PNG): contain it in a padded, bordered tile + and drop the gradient. The backdrop is chosen from the logo's luminance in + store.js (hydrateIcons); a neutral panel shows until then. */ +img.avatar.icon { + background: var(--panel); border: 1px solid var(--border); + padding: 5px; object-fit: contain; +} +img.avatar.icon.icon-dark { background: #0b1018; } +img.avatar.icon.icon-light { background: #ffffff; border-color: rgba(0, 0, 0, 0.12); } .card h3 { margin: 0; font-size: 16px; } .card .summary { color: var(--muted); font-size: 14px; margin: 0; min-height: 38px; } .card .meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: auto; } diff --git a/apps/extensions/public/store.js b/apps/extensions/public/store.js index 2ef873d..ab63b69 100644 --- a/apps/extensions/public/store.js +++ b/apps/extensions/public/store.js @@ -8,10 +8,52 @@ function qs(name) { return new URLSearchParams(location.search).get(name); } function initials(name) { return (name || '?').trim().split(/\s+/).slice(0, 2).map((w) => w[0] || '').join('').toUpperCase(); } // Logo if the listing has one (auto-ingested from the .crx icons), else initials. +// Most logos are transparent PNGs, so we render them CONTAINED (never cropped) +// and pick a light or dark backdrop from the logo's own luminance in +// hydrateIcons() — otherwise a transparent logo shows over the gradient avatar +// and looks muddy. function avatar(ext, px) { - const style = px ? ` style="width:${px}px;height:${px}px"` : ''; - if (ext.iconUrl) return ``; - return `
${esc(initials(ext.name))}
`; + const dim = px ? `width:${px}px;height:${px}px` : ''; + if (ext.iconUrl) return ``; + return `
${esc(initials(ext.name))}
`; +} + +// Choose 'dark' or 'light' backdrop for a (usually transparent) logo by the +// average luminance of its opaque pixels: a bright logo wants a dark tile, a +// dark logo a light tile. Returns null if unreadable (fully transparent / a +// CORS-tainted canvas), leaving the default neutral tile. +function backdropFor(img) { + try { + const s = 24; + const c = document.createElement('canvas'); + c.width = s; c.height = s; + const ctx = c.getContext('2d', { willReadFrequently: true }); + ctx.drawImage(img, 0, 0, s, s); + const { data } = ctx.getImageData(0, 0, s, s); + let lum = 0, wsum = 0; + for (let i = 0; i < data.length; i += 4) { + const a = data[i + 3] / 255; + if (a < 0.1) continue; + lum += (0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2]) * a; + wsum += a; + } + if (wsum === 0) return null; + return lum / wsum > 140 ? 'dark' : 'light'; + } catch (_) { + return 'dark'; // tainted canvas (remote icon) — dark suits the store theme + } +} + +function hydrateIcons(root) { + root.querySelectorAll('img.avatar.icon[data-icon]').forEach((img) => { + const apply = () => { + const b = backdropFor(img); + if (b) img.classList.add('icon-' + b); + img.removeAttribute('data-icon'); + }; + if (img.complete && img.naturalWidth) apply(); + else img.addEventListener('load', apply, { once: true }); + }); } // ── AI auto-ingest: paste a .crx URL, we fill the form + scan (no forms) ── @@ -115,6 +157,7 @@ async function initBrowse() { if (!extensions.length) { grid.innerHTML = ''; empty.classList.remove('hidden'); return; } empty.classList.add('hidden'); grid.innerHTML = extensions.map(card).join(''); + hydrateIcons(grid); } catch (e) { grid.innerHTML = `

Couldn't load extensions: ${esc(e.message)}

`; } @@ -162,6 +205,7 @@ async function initDetail() {

Auto-update

Chromium auto-updates this extension from its update_url:

${esc(ext.updateUrl)}
`; + hydrateIcons(root); document.getElementById('flagBtn').addEventListener('click', async () => { const reason = prompt('Reason? (malware, privacy, broken, spam, other)', 'other');