oss.hanzo.ai: bulletproof gallery — kill CSP-blocked inline handlers, DOM-built + resilient

The inline img onerror= handler was blocked by script-src 'self' → console-error
storm on all 1030 cards + dead-feeling page. Rebuild: cards built via DOM API
(never innerHTML — XSS-safe, CSP-clean), logo error handled via addEventListener
(graceful monogram fallback), render wrapped so one bad row is skipped not fatal,
graceful catalog-load failure. Apple-tier CSS polish (calm monochrome, refined
hover, rhythm). Reliable + resilient by construction.
This commit is contained in:
2026-07-24 16:44:57 -07:00
parent 5325a4f88e
commit 2dedaf3221
2 changed files with 183 additions and 129 deletions
+105 -69
View File
@@ -1,77 +1,100 @@
// Hanzo OSS explorer — fetches the catalog and renders a fast, searchable gallery.
// Static-served (hanzoai/static): same-origin fetch of /meta.json, no framework.
// oss.hanzo.ai — the open-source explorer. One file, no framework, no inline JS
// (CSP: script-src 'self'). Cards are built with the DOM API — never innerHTML —
// so a hostile description can't inject, a missing logo can't break the grid, and
// nothing here runs afoul of the strict Content-Security-Policy. Resilient by
// construction: one bad template is skipped, never fatal.
'use strict';
var PLATFORM = 'https://platform.hanzo.ai/templates?deploy=';
var AUTHORS = 'https://console.hanzo.ai/authors';
var PAGE = 48;
// Build-provenance tags are catalog plumbing, not user categories.
var DEPLOY = 'https://platform.hanzo.ai/templates?deploy=';
var EARN = 'https://console.hanzo.ai/authors';
var PAGE = 60;
// Build-provenance tags are catalog plumbing, not categories a human browses by.
var HIDE = { caprover: 1, dokploy: 1, coolify: 1, casaos: 1, runtipi: 1 };
var FEATURED = ['self-hosted', 'ai', 'database', 'media', 'productivity', 'monitoring', 'automation', 'developer'];
var all = [], view = [], shown = 0, active = '';
var $ = function (id) { return document.getElementById(id); };
var el = function (tag, cls, text) {
var n = document.createElement(tag);
if (cls) n.className = cls;
if (text != null) n.textContent = text;
return n;
};
var grid = document.getElementById('grid');
var q = document.getElementById('q');
var tagsEl = document.getElementById('tags');
var moreBtn = document.getElementById('more');
var emptyEl = document.getElementById('empty');
var countEl = document.getElementById('count');
var ALL = [], view = [], shown = 0, active = '';
var grid, q, tagsEl, moreBtn, emptyEl, countEl;
function ghRepo(t) {
var u = (t.links && t.links.github) || '';
var m = u.match(/github\.com\/([^/]+)\/([^/#?]+)/);
return m ? (m[1] + '/' + m[2].replace(/\.git$/, '')) : '';
return m ? m[1] + '/' + m[2].replace(/\.git$/, '') : '';
}
// A logo that degrades gracefully: try the image; on error (or when absent),
// fall back to a monogram tile. No inline handler — bound here, CSP-clean.
function logo(t) {
var initial = (t.name || t.id || '?').charAt(0).toUpperCase();
var mono = el('div', 'logo mono', initial);
if (!t.logo) return mono;
var img = el('img', 'logo');
img.loading = 'lazy';
img.alt = '';
img.src = '/blueprints/' + encodeURIComponent(t.id) + '/' + t.logo;
img.addEventListener('error', function () {
if (img.parentNode) img.parentNode.replaceChild(mono, img);
});
return img;
}
function card(t) {
var el = document.createElement('article');
el.className = 'card';
var repo = ghRepo(t);
var logo = t.logo
? '<img class="logo" loading="lazy" alt="" src="/blueprints/' + t.id + '/' + t.logo + '" onerror="this.replaceWith(mono(\'' + (t.name || t.id).replace(/[^a-z0-9]/gi, '') + '\'))">'
: monoHTML(t.name || t.id);
el.innerHTML =
logo +
'<div class="body">' +
'<p class="name">' + esc(t.name || t.id) + '</p>' +
'<p class="desc">' + esc(t.description || '') + '</p>' +
'<div class="row">' +
'<a class="deploy" href="' + PLATFORM + encodeURIComponent(t.id) + '">Deploy</a>' +
(t.links && t.links.github ? '<a class="link" target="_blank" rel="noopener" href="' + esc(t.links.github) + '">GitHub</a>' : '') +
(repo ? '<a class="claim" title="Are you the maintainer?" href="' + AUTHORS + '?claim=' + encodeURIComponent(repo) + '">Earn 20% →</a>' : '') +
'</div>' +
'</div>';
return el;
}
var c = el('article', 'card');
// exposed for the onerror fallback
window.mono = function (letter) {
var d = document.createElement('div');
d.className = 'logo mono';
d.textContent = (letter || '?').charAt(0).toUpperCase();
return d;
};
function monoHTML(name) {
return '<div class="logo mono">' + esc((name || '?').charAt(0).toUpperCase()) + '</div>';
c.appendChild(logo(t));
var body = el('div', 'body');
body.appendChild(el('p', 'name', t.name || t.id));
body.appendChild(el('p', 'desc', t.description || ''));
var row = el('div', 'row');
var deploy = el('a', 'deploy', 'Deploy');
deploy.href = DEPLOY + encodeURIComponent(t.id);
row.appendChild(deploy);
if (t.links && t.links.github) {
var gh = el('a', 'link', 'GitHub');
gh.href = t.links.github; gh.target = '_blank'; gh.rel = 'noopener noreferrer';
row.appendChild(gh);
}
var repo = ghRepo(t);
if (repo) {
var earn = el('a', 'claim', 'Earn 20% →');
earn.href = EARN + '?claim=' + encodeURIComponent(repo);
earn.title = 'Are you the maintainer? Earn 20% of the compute revenue.';
row.appendChild(earn);
}
body.appendChild(row);
c.appendChild(body);
return c;
}
function esc(s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; }); }
function render(reset) {
if (reset) { grid.innerHTML = ''; shown = 0; }
if (reset) { grid.textContent = ''; shown = 0; }
var frag = document.createDocumentFragment();
var end = Math.min(shown + PAGE, view.length);
for (var i = shown; i < end; i++) frag.appendChild(card(view[i]));
for (var i = shown; i < end; i++) {
try { frag.appendChild(card(view[i])); } catch (e) { /* skip one bad row, never break the grid */ }
}
grid.appendChild(frag);
shown = end;
moreBtn.hidden = shown >= view.length;
emptyEl.hidden = view.length > 0;
if (countEl) countEl.textContent = ALL.length.toLocaleString();
}
function apply() {
var term = q.value.trim().toLowerCase();
view = all.filter(function (t) {
if (active && !(t.tags || []).some(function (x) { return x === active; })) return false;
view = ALL.filter(function (t) {
if (active && (t.tags || []).indexOf(active) < 0) return false;
if (!term) return true;
return (t.name || '').toLowerCase().indexOf(term) >= 0 ||
(t.description || '').toLowerCase().indexOf(term) >= 0 ||
@@ -81,35 +104,48 @@ function apply() {
render(true);
}
var timer;
q.addEventListener('input', function () { clearTimeout(timer); timer = setTimeout(apply, 90); });
moreBtn.addEventListener('click', function () { render(false); });
function buildTags() {
FEATURED.forEach(function (name) {
if (HIDE[name]) return;
var b = document.createElement('button');
b.className = 'tag'; b.textContent = name; b.dataset.tag = name;
var b = el('button', 'tag', name);
b.addEventListener('click', function () {
if (active === name) { active = ''; b.classList.remove('on'); }
else {
active = name;
Array.prototype.forEach.call(tagsEl.children, function (c) { c.classList.remove('on'); });
b.classList.add('on');
}
var on = active === name;
Array.prototype.forEach.call(tagsEl.children, function (c) { c.classList.remove('on'); });
active = on ? '' : name;
if (!on) b.classList.add('on');
apply();
});
tagsEl.appendChild(b);
});
}
fetch('/meta.json')
.then(function (r) { return r.json(); })
.then(function (data) {
all = Array.isArray(data) ? data : [];
if (countEl) countEl.textContent = all.length.toLocaleString();
buildTags();
view = all;
render(true);
})
.catch(function () { emptyEl.hidden = false; emptyEl.textContent = 'Catalog failed to load — try /meta.json directly.'; });
function fail(msg) {
if (emptyEl) { emptyEl.hidden = false; emptyEl.textContent = msg; }
}
function start() {
grid = $('grid'); q = $('q'); tagsEl = $('tags'); moreBtn = $('more');
emptyEl = $('empty'); countEl = $('count');
if (!grid) return;
moreBtn.addEventListener('click', function () { render(false); });
var timer;
q.addEventListener('input', function () { clearTimeout(timer); timer = setTimeout(apply, 80); });
fetch('/meta.json', { cache: 'default' })
.then(function (r) { if (!r.ok) throw new Error(r.status); return r.json(); })
.then(function (data) {
ALL = (Array.isArray(data) ? data : []).filter(function (t) { return t && t.id; });
if (!ALL.length) return fail('Catalog is empty.');
buildTags();
view = ALL;
render(true);
})
.catch(function () { fail('Catalog failed to load. Try /meta.json directly.'); });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start);
} else {
start();
}
+78 -60
View File
@@ -1,103 +1,121 @@
/* oss.hanzo.ai — the open-source explorer. Monochrome, Geist, calm.
The apple of open source: restraint, rhythm, one accent (the deploy button). */
:root {
--bg: #000; --panel: #0c0c0d; --line: #1c1c1f; --line2: #2a2a2e;
--fg: #fafafa; --muted: #8a8a8f; --muted2: #5a5a5f;
--accent: #fafafa;
--bg: #000; --panel: #0b0b0c; --panel-hi: #101012;
--line: #1a1a1d; --line-hi: #2a2a2f;
--fg: #fafafa; --muted: #8f8f96; --faint: #5a5a61;
--radius: 14px;
--ease: cubic-bezier(0.22, 1, 0.36, 1);
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; }
body {
background: var(--bg); color: var(--fg);
font: 15px/1.55 ui-sans-serif, -apple-system, "Geist", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
margin: 0; background: var(--bg); color: var(--fg);
font: 15px/1.55 ui-sans-serif, -apple-system, "Geist", "Inter", "Segoe UI", Roboto, system-ui, sans-serif;
-webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility;
}
a { color: inherit; text-decoration: none; }
::selection { background: #fff; color: #000; }
/* top bar */
.top {
position: sticky; top: 0; z-index: 20;
display: flex; align-items: center; justify-content: space-between;
padding: 1rem 1.5rem; border-bottom: 1px solid var(--line);
position: sticky; top: 0; background: rgba(0,0,0,.72); backdrop-filter: blur(12px); z-index: 10;
padding: 1.05rem 1.75rem;
background: rgba(0,0,0,.65); backdrop-filter: saturate(180%) blur(16px);
border-bottom: 1px solid var(--line);
}
.brand { font-weight: 600; letter-spacing: -0.02em; }
.topnav { display: flex; gap: 1.25rem; font-size: .9rem; color: var(--muted); }
.brand { font-weight: 600; letter-spacing: -0.03em; font-size: 1.05rem; }
.topnav { display: flex; gap: 1.5rem; font-size: .9rem; color: var(--muted); }
.topnav a { transition: color .15s var(--ease); }
.topnav a:hover { color: var(--fg); }
/* hero */
.hero { max-width: 62rem; margin: 0 auto; padding: 3.5rem 1.5rem 1.5rem; text-align: center; }
.hero h1 { font-size: clamp(2rem, 5vw, 3.25rem); line-height: 1.05; letter-spacing: -0.03em; margin: 0 0 .6rem; font-weight: 600; }
.lead { font-size: 1.15rem; color: var(--muted); margin: 0 0 1rem; }
.lead strong { color: var(--fg); }
.earn {
display: inline-block; font-size: .9rem; color: var(--muted);
background: var(--panel); border: 1px solid var(--line); border-radius: 999px;
padding: .5rem 1rem; margin-bottom: 1.75rem;
.hero { max-width: 60rem; margin: 0 auto; padding: 4.5rem 1.5rem 2rem; text-align: center; }
.hero h1 {
margin: 0 0 .75rem; font-weight: 600; letter-spacing: -0.035em;
font-size: clamp(2.25rem, 6vw, 4rem); line-height: 1.02;
}
.earn a { color: var(--fg); text-decoration: underline; text-underline-offset: 2px; }
.lead { margin: 0 auto 1.25rem; max-width: 34rem; font-size: 1.2rem; color: var(--muted); }
.lead strong { color: var(--fg); font-weight: 600; }
.earn {
display: inline-block; margin-bottom: 2rem; padding: .55rem 1.15rem;
font-size: .9rem; color: var(--muted);
background: var(--panel); border: 1px solid var(--line); border-radius: 999px;
transition: border-color .15s var(--ease);
}
.earn:hover { border-color: var(--line-hi); }
.earn a { color: var(--fg); border-bottom: 1px solid var(--line-hi); }
.search {
width: 100%; max-width: 40rem; display: block; margin: 0 auto;
background: var(--panel); border: 1px solid var(--line2); border-radius: 12px;
color: var(--fg); font-size: 1rem; padding: .85rem 1.1rem; outline: none;
display: block; width: 100%; max-width: 42rem; margin: 0 auto;
padding: .95rem 1.25rem; font-size: 1rem; color: var(--fg);
background: var(--panel); border: 1px solid var(--line-hi); border-radius: 12px;
outline: none; transition: border-color .15s var(--ease), background .15s var(--ease);
}
.search:focus { border-color: #444; }
.search::placeholder { color: var(--muted2); }
.search::placeholder { color: var(--faint); }
.search:focus { border-color: #555; background: var(--panel-hi); }
.tags { display: flex; flex-wrap: wrap; gap: .5rem; justify-content: center; margin-top: 1.1rem; }
.tags { display: flex; flex-wrap: wrap; gap: .5rem; justify-content: center; margin-top: 1.25rem; }
.tag {
font-size: .8rem; color: var(--muted); background: var(--panel);
border: 1px solid var(--line); border-radius: 999px; padding: .3rem .8rem; cursor: pointer;
text-transform: capitalize;
padding: .35rem .85rem; font-size: .82rem; color: var(--muted); text-transform: capitalize;
background: var(--panel); border: 1px solid var(--line); border-radius: 999px;
cursor: pointer; transition: all .15s var(--ease);
}
.tag:hover { color: var(--fg); border-color: var(--line2); }
.tag.on { color: #000; background: var(--fg); border-color: var(--fg); }
.tag:hover { color: var(--fg); border-color: var(--line-hi); }
.tag.on { color: #000; background: var(--fg); border-color: var(--fg); font-weight: 500; }
/* grid */
main { max-width: 82rem; margin: 0 auto; padding: 1.5rem 1.5rem 4rem; }
.grid {
display: grid; gap: .75rem;
grid-template-columns: repeat(auto-fill, minmax(19rem, 1fr));
}
main { max-width: 84rem; margin: 0 auto; padding: 1.5rem 1.5rem 5rem; }
.grid { display: grid; gap: .85rem; grid-template-columns: repeat(auto-fill, minmax(19.5rem, 1fr)); }
.card {
content-visibility: auto; contain-intrinsic-size: 0 108px;
display: flex; gap: .85rem; align-items: flex-start;
background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
padding: 1rem; position: relative; transition: border-color .12s;
content-visibility: auto; contain-intrinsic-size: 0 112px;
display: flex; gap: .9rem; align-items: flex-start;
padding: 1.1rem; background: var(--panel);
border: 1px solid var(--line); border-radius: var(--radius);
transition: border-color .16s var(--ease), background .16s var(--ease), transform .16s var(--ease);
}
.card:hover { border-color: var(--line2); }
.card:hover { border-color: var(--line-hi); background: var(--panel-hi); transform: translateY(-1px); }
.logo {
flex: 0 0 40px; width: 40px; height: 40px; border-radius: 9px;
background: #fff; object-fit: contain; padding: 5px;
display: grid; place-items: center; overflow: hidden;
flex: 0 0 42px; width: 42px; height: 42px; border-radius: 10px;
object-fit: contain; background: #fff; padding: 5px;
}
.logo.mono { background: var(--line2); color: var(--fg); font-weight: 600; font-size: 1.1rem; }
.body { min-width: 0; flex: 1; }
.name { font-weight: 600; letter-spacing: -0.01em; margin: 0; font-size: .98rem; }
.logo.mono {
display: grid; place-items: center; padding: 0;
background: var(--line-hi); color: var(--fg); font-weight: 600; font-size: 1.15rem;
}
.body { flex: 1; min-width: 0; }
.name { margin: 0; font-size: 1rem; font-weight: 600; letter-spacing: -0.015em; }
.desc {
color: var(--muted); font-size: .82rem; margin: .2rem 0 .55rem;
margin: .25rem 0 .7rem; font-size: .83rem; color: var(--muted); line-height: 1.45;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.row { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
.row { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
.deploy {
font-size: .8rem; font-weight: 500; color: #000; background: var(--fg);
border-radius: 8px; padding: .3rem .7rem;
padding: .34rem .8rem; font-size: .8rem; font-weight: 600; color: #000;
background: var(--fg); border-radius: 8px; transition: background .15s var(--ease);
}
.deploy:hover { background: #d4d4d4; }
.link { font-size: .78rem; color: var(--muted); }
.deploy:hover { background: #cfcfcf; }
.link { font-size: .8rem; color: var(--muted); transition: color .15s var(--ease); }
.link:hover { color: var(--fg); }
.claim { font-size: .74rem; color: var(--muted2); margin-left: auto; }
.claim { margin-left: auto; font-size: .76rem; color: var(--faint); transition: color .15s var(--ease); }
.claim:hover { color: var(--fg); }
.more {
display: block; margin: 2rem auto 0; background: var(--panel); color: var(--fg);
border: 1px solid var(--line2); border-radius: 10px; padding: .7rem 1.5rem; cursor: pointer; font-size: .9rem;
display: block; margin: 2.5rem auto 0; padding: .75rem 1.75rem;
font-size: .9rem; color: var(--fg); background: var(--panel);
border: 1px solid var(--line-hi); border-radius: 10px; cursor: pointer;
transition: border-color .15s var(--ease);
}
.more:hover { border-color: #444; }
.empty { text-align: center; color: var(--muted); padding: 3rem 0; }
.more:hover { border-color: #555; }
.empty { padding: 4rem 0; text-align: center; color: var(--muted); }
.foot {
max-width: 82rem; margin: 0 auto; padding: 2rem 1.5rem; border-top: 1px solid var(--line);
max-width: 84rem; margin: 0 auto; padding: 2.5rem 1.5rem;
display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap;
color: var(--muted2); font-size: .8rem;
border-top: 1px solid var(--line); color: var(--faint); font-size: .8rem;
}
.foot a { color: var(--muted); }
.foot a:hover { color: var(--fg); }
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }