oss.hanzo.ai: one page — editorial hero + searchable launcher; kill the public %, fix the CSS

Merge the two half-storefronts into ONE canonical page:
- KEEP the educational story (hero · beliefs · makers band).
- FOLD IN the searchable 1,030-app launcher below it: search box, 16
  data-driven category chips, paginated card grid (Deploy + Source),
  load-more, #q=&tag= URL sync, / and Esc keys.
- STRIP every public compute-share percentage — the makers band now
  promises a share metered per second, paid automatically, no number.
- ONE reconciled stylesheet: theme-aware (light default, dark via OS +
  [data-theme] toggle persisted to localStorage), responsive 390→1680,
  no horizontal body scroll. Editorial sections converted from hardcoded
  black/white to design tokens.
- ONE JS: theme bootstrap (blocking in <head>, no flash) + launcher.
  DOM-built, never innerHTML; logos degrade to a monogram on error.

meta.json + blueprints (the machine contract the PaaS loader reads) are
untouched. Verified headless under the exact prod CSP at 390 + 1280:
hero + real cards render, search + chip filter work, Deploy href =
platform.hanzo.ai/templates?deploy=<id>, zero CSP/JS errors, zero '%',
theme toggle persists, scrollWidth<=innerWidth.
This commit is contained in:
2026-07-24 23:19:46 -07:00
parent ee73854c15
commit 402659d209
4 changed files with 447 additions and 438 deletions
+35 -28
View File
@@ -43,35 +43,42 @@ CI → `ghcr.io/hanzoai/templates-blueprints:sha-<short>-amd64` (arc pool
`templates.hanzo.ai`, TLS via the ingress. DNS: `templates.hanzo.ai` A → the DOKS `templates.hanzo.ai`, TLS via the ingress. DNS: `templates.hanzo.ai` A → the DOKS
ingress LB. ingress LB.
## The explorer UI (oss.hanzo.ai) — index.html + catalog.css + app.js ## The storefront UI (oss.hanzo.ai / templates.hanzo.ai) — index.html + catalog.css + app.js
The three presentation files are the ONLY moving parts of the storefront; they ONE page, three files: the editorial story (hero · beliefs · makers band) above a
never touch the contract above (meta.json / blueprints are read-only to them). searchable 1,030-app launcher. The three presentation files are the ONLY moving
`static` serves a strict CSP: `script-src 'self'` (external app.js, NO inline parts; they never touch the contract above (meta.json / blueprints are read-only
`<script>` / handlers), `img-src 'self' data:` (logos same-origin, favicon + to them). `static` serves a strict CSP — verified live:
brand mark are inline data: SVGs — NEVER an external favicon URL), `style-src `default-src 'self'; script-src 'self'; connect-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self'; base-uri 'self'; frame-ancestors 'none'`.
'self' 'unsafe-inline'` (external catalog.css + inline `<style>` OK), `connect-src So: external `app.js` (NO inline `<script>` / handlers), logos same-origin +
'self'` (only `GET /meta.json`). Verify before shipping: `node --check app.js`, favicon/brand-mark inline data:/SVG (NEVER an external favicon), external
`grep -E 'onerror=|onclick=|onload=' *.js *.html` = 0. `catalog.css`, only `GET /meta.json` on the wire, system-font stack (NO web font).
Verify before shipping: `node --check app.js`; `grep -E 'onerror=|onclick=|onload=' *.js *.html` = 0;
and no public compute-share percentage appears in index.html/catalog.css/app.js (the CTO killed the public number).
Invariants that must not regress: Invariants that must not regress:
- **DOM-built, never innerHTML.** Cards come from one factory `card(t, opts)` - **DOM-built, never innerHTML.** Cards come from one factory `card(t)` (logo +
(`opts.spotlight`/`opts.note` = the "Start here" dress). Per-item try/catch — name + category badge + Deploy + Source). Per-item try/catch — one bad row is
one bad row is skipped, never fatal. Logos degrade to a monogram tile on skipped, never fatal. Logos degrade to a monogram tile on `error` (proven: page
`error` (proven: page stays whole even if the server vanishes). stays whole even if a logo 404s or the server vanishes).
- **The story is static HTML** (hero · beliefs · makers band) so it first-paints - **The story is static HTML** (hero · beliefs · makers band) so it first-paints
instantly and survives a `/meta.json` failure; only the catalog below needs data. instantly and survives a `/meta.json` failure; only the launcher needs data.
- **Curated exploration, not a wall of 1030.** `FEATURED` = ~12 hand-picked ids - **Search + category chips, not a wall of 1030.** The chip row is **data-driven**:
+ one-liners. `DOMAINS` = 8 browse-domains, each a curated **set of real tags** a `PREFERRED` order (ai, llm, database, …) shown first when present, then the
(build-provenance tags caprover/dokploy/coolify/casaos/runtipi are NEVER in a most-common remaining tags fill up to 16; build-provenance tags
set); filtering is one intersection test. Domain cards + the clear-chip both (caprover/dokploy/coolify/casaos/runtipi/docker/free/open-source) are hidden.
call `setDomain()` — one path. Search is the power path. A chip filters by one exact-tag test; search (name+desc+id+tags) is the power
- **The 1030-grid is deferred** behind an IntersectionObserver (renders on scroll path. State is shareable in the URL hash (`#q=…&tag=…`); `/` focuses search,
or first search/filter), paginated 60 at a time, `content-visibility:auto` + `Esc` clears. Grid paginated 48 at a time, `content-visibility:auto` + lazy logos.
lazy logos. Keep it cheap. - **Theme-aware.** Light default; dark via `prefers-color-scheme` and an explicit
- **Economics are first-class:** Deploy → `platform.hanzo.ai/templates?deploy=<id>`; `[data-theme]` toggle persisted to `localStorage['hz-theme']`. `app.js` is loaded
maintainer "Earn 20% →" → `console.hanzo.ai/authors?claim=<owner/repo>` parsed **blocking in `<head>`** so the theme bootstrap runs before first paint (no flash);
from `links.github`. Copy is honest — real count (1,030), real 20%, Techstars '17. the catalog work waits for `DOMContentLoaded`. ONE JS file (theme + launcher).
- Motion: hero rises on load (always ends visible); below-fold reveal is opt-in - **Economics are first-class, but the public % is gone:** Deploy →
only under `@supports (animation-timeline: view())` so unsupported browsers and `platform.hanzo.ai/templates?deploy=<id>` (proven live 200). The makers band
`prefers-reduced-motion` always render fully visible. promises a **share of the compute a project earns — metered per second, paid out
automatically**, with NO percentage anywhere (CTO killed the public number). The
"Claim your project" CTA → `console.hanzo.ai/authors`. Copy stays honest: real
count (1,030), Techstars '17.
- Motion: hero rises once on load (always ends visible); `prefers-reduced-motion`
disables all animation.
+175 -212
View File
@@ -1,152 +1,124 @@
// oss.hanzo.ai — the open-source explorer. One file, no framework, no inline JS // oss.hanzo.ai — one file, no framework, no inline JS (CSP: script-src 'self').
// (CSP: script-src 'self'). Everything is built with the DOM API — never // Loaded blocking in <head> so the theme is applied before first paint (no
// innerHTML — so a hostile description can't inject, a missing logo can't break // flash); the catalog work waits for DOMContentLoaded. Everything is built with
// the grid, and nothing runs afoul of the strict Content-Security-Policy. // the DOM API — never innerHTML — so a hostile description can't inject, a
// Resilient by construction: the story (hero, beliefs, makers) is static HTML // missing logo can't break the grid, and one bad row is skipped, never fatal.
// that stands alone; only the catalog below needs data, and one bad row is // Reads the SAME /meta.json + /blueprints/<id>/ the PaaS loader consumes.
// skipped, never fatal.
'use strict'; 'use strict';
var DEPLOY = 'https://platform.hanzo.ai/templates?deploy='; /* ---------------------------------------------------------------- theme ---- */
var EARN = 'https://console.hanzo.ai/authors'; // Applied immediately (this script is blocking in <head>): with an explicit
var PAGE = 60; // saved choice we stamp [data-theme] before the body paints; with none, the CSS
// follows the OS via prefers-color-scheme. The toggle is wired once the DOM is up.
(function () {
var KEY = 'hz-theme';
try {
var saved = localStorage.getItem(KEY);
if (saved === 'light' || saved === 'dark') document.documentElement.setAttribute('data-theme', saved);
} catch (e) { /* private mode / storage blocked — fall back to OS */ }
// The eight domains a human actually browses by. Each maps to a set of real function effective() {
// tags (build-provenance tags — caprover/dokploy/coolify/casaos/runtipi — are var set = document.documentElement.getAttribute('data-theme');
// never here). Filtering is one intersection test, parameterised by the set. if (set) return set;
var DOMAINS = [ return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
{ key: 'self-hosted', label: 'Self-hosted', }
blurb: 'Run it on your own hardware. Your data never leaves the building.', function wireToggle() {
tags: ['self-hosted', 'selfhosted', 'homelab', 'privacy', 'local-first', 'home'] }, var btn = document.getElementById('theme-toggle');
{ key: 'ai', label: 'AI', if (!btn) return;
blurb: 'Local LLMs, vector search, agents — the whole AI stack, yours to run.', btn.addEventListener('click', function () {
tags: ['ai', 'llm', 'machine-learning', 'chatbot', 'openai', 'anthropic', 'rag', 'agent', 'agents', 'ollama', 'langchain', 'llmops', 'nlp', 'inference', 'generative-ai', 'artificial-intelligence'] }, var next = effective() === 'dark' ? 'light' : 'dark';
{ key: 'database', label: 'Databases', document.documentElement.setAttribute('data-theme', next);
blurb: 'Where your data lives. Postgres, Redis, and everything that remembers.', try { localStorage.setItem(KEY, next); } catch (e) { /* ignore */ }
tags: ['database', 'databases', 'postgres', 'postgresql', 'postgresdb', 'pgsql', 'mysql', 'mariadb', 'redis', 'mongodb', 'sqlite', 'sql', 'key-value', 'time-series', 'olap', 'vector-database', 'vector-db', 'clickhouse', 'surrealdb', 'nosql'] }, });
{ key: 'media', label: 'Media', }
blurb: 'Movies, music, photos — your own streaming service, no subscription.', onReady(wireToggle);
tags: ['media', 'media-server', 'media-system', 'streaming', 'video', 'videos', 'music', 'movies', 'photos', 'photo', 'tv', 'audio', 'podcast', 'podcasts', 'plex', 'jellyfin', 'live-streaming'] }, })();
{ key: 'productivity', label: 'Productivity',
blurb: 'Notes, docs, tasks, calendars — the tools you live in, self-owned.',
tags: ['productivity', 'notes', 'note-taking', 'notebook', 'documents', 'tasks', 'todo', 'calendar', 'kanban', 'project-management', 'projectmanagement', 'knowledge-base', 'knowledge-management', 'wiki', 'office', 'collaboration', 'crm', 'erp'] },
{ key: 'monitoring', label: 'Monitoring',
blurb: 'Watch your systems breathe. Metrics, logs, and alerts before users notice.',
tags: ['monitoring', 'observability', 'metrics', 'logging', 'logs', 'alerting', 'alerts', 'apm', 'uptime', 'status-page', 'status', 'tracing', 'dashboard', 'analytics'] },
{ key: 'automation', label: 'Automation',
blurb: 'Wire anything to anything. Let the robots do the boring parts.',
tags: ['automation', 'workflow', 'workflows', 'low-code', 'no-code', 'nocode', 'lowcode', 'integration', 'orchestration', 'scheduler', 'cron', 'background-jobs', 'background-tasks', 'queue', 'messaging'] },
{ key: 'developer', label: 'Developer',
blurb: 'Git, CI, APIs, and backends — the machinery you ship software with.',
tags: ['developer', 'development', 'developer-tools', 'devtools', 'git', 'ci-cd', 'ci', 'cd', 'version-control', 'versioncontrol', 'code', 'devops', 'api', 'backend', 'ide', 'serverless', 'docker'] }
];
var DOMAP = {};
DOMAINS.forEach(function (d) { d.set = {}; d.tags.forEach(function (t) { d.set[t] = 1; }); DOMAP[d.key] = d; });
// Start here — hand-picked, iconic, with a plain-English line for what it IS. /* --------------------------------------------------------------- catalog --- */
// Rendered through the same card factory as the grid (one renderer), just in var DEPLOY = 'https://platform.hanzo.ai/templates?deploy='; // PROVEN live (200)
// spotlight dress. A pick missing from the catalog is silently skipped. var CATALOG = '/meta.json';
var FEATURED = [ var LOGO_BASE = '/blueprints/';
['supabase', 'The open-source Firebase. Postgres, auth, and APIs in one.'], var PAGE = 48;
['n8n', 'Wire anything to anything. Automation without the glue code.'],
['ollama', 'Run open LLMs on your own metal. No API keys, no limits.'],
['ghost', 'Publishing without the ads. A newsletter and blog you own.'],
['postgres', 'The database the internet is built on.'],
['grafana', 'See everything, all at once. Dashboards for any metric.'],
['nextcloud', 'Your files, your cloud. Drive, calendar, and contacts, self-hosted.'],
['immich', 'Google Photos, but yours. Every photo, backed up at home.'],
['gitea', 'Git hosting you control. Lightweight, fast, all yours.'],
['vaultwarden', 'Passwords you actually own. A Bitwarden server of your own.'],
['uptime-kuma', 'Know before your users do. Uptime monitoring, beautifully simple.'],
['redis', 'Memory at the speed of light. The cache behind everything fast.']
];
var $ = function (id) { return document.getElementById(id); }; // Build-provenance tags are catalog plumbing, not user-facing categories.
var el = function (tag, cls, text) { var HIDE = { caprover: 1, dokploy: 1, coolify: 1, casaos: 1, runtipi: 1, docker: 1, free: 1, 'open-source': 1 };
// Recognizable categories shown first (only if present in the data), then the
// most common remaining tags fill the bar — so it always reflects the real set.
var PREFERRED = ['ai', 'llm', 'database', 'automation', 'monitoring', 'analytics',
'media', 'cms', 'productivity', 'security', 'storage', 'development', 'devtools',
'communication', 'networking', 'finance', 'self-hosted'];
var MAX_TAGS = 16;
var LABEL = { ai: 'AI', llm: 'LLM', cms: 'CMS', api: 'API', crm: 'CRM', erp: 'ERP',
dns: 'DNS', vpn: 'VPN', iot: 'IoT', ci: 'CI', cd: 'CD', devtools: 'Dev tools',
s3: 'S3', 'self-hosted': 'Self-hosted' };
// Too generic to be a useful per-card badge.
var GENERIC = { 'self-hosted': 1, 'open-source': 1, docker: 1, free: 1, app: 1, web: 1, hosting: 1, utilities: 1 };
var all = [], view = [], shown = 0, active = '';
var grid, q, tagsEl, moreBtn, emptyEl, resultEl;
function onReady(fn) {
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn);
else fn();
}
function el(tag, cls, text) {
var n = document.createElement(tag); var n = document.createElement(tag);
if (cls) n.className = cls; if (cls) n.className = cls;
if (text != null) n.textContent = text; if (text != null) n.textContent = text;
return n; return n;
};
var ALL = [], BYID = {}, view = [], shown = 0, active = '';
var armed = false, io = null;
var grid, q, moreBtn, emptyEl, countEl, featuredEl, domainsEl, chipEl, resultEl, catalogEl;
function ghRepo(t) {
var u = (t.links && t.links.github) || '';
var m = u.match(/github\.com\/([^/]+)\/([^/#?]+)/);
return m ? m[1] + '/' + m[2].replace(/\.git$/, '') : '';
} }
function label(tag) { return LABEL[tag] || (tag.charAt(0).toUpperCase() + tag.slice(1)); }
function githubUrl(t) { return (t.links && t.links.github) || ''; }
// A logo that degrades gracefully: try the image; on error (or when absent), // The most specific, non-generic tag — the card's category badge.
// fall back to a monogram tile. No inline handler — bound here, CSP-clean. function badgeTag(t) {
function logo(t) { var tags = t.tags || [];
var initial = (t.name || t.id || '?').charAt(0).toUpperCase(); for (var i = 0; i < tags.length; i++) if (!GENERIC[tags[i]] && !HIDE[tags[i]]) return tags[i];
var mono = el('div', 'logo mono', initial); for (var j = 0; j < tags.length; j++) if (!HIDE[tags[j]]) return tags[j];
if (!t.logo) return mono; return '';
}
function monoNode(name) { return el('div', 'logo mono', (name || '?').charAt(0).toUpperCase()); }
// One card factory. Logo degrades to a monogram on error (bound in JS — no
// inline onerror, CSP-clean). Deploy is the one-click deep link; Source is the repo.
function card(t) {
var c = el('article', 'card');
if (t.logo) {
var img = el('img', 'logo'); var img = el('img', 'logo');
img.loading = 'lazy'; img.loading = 'lazy'; img.alt = '';
img.alt = ''; img.src = LOGO_BASE + encodeURIComponent(t.id) + '/' + encodeURIComponent(t.logo);
img.src = '/blueprints/' + encodeURIComponent(t.id) + '/' + t.logo; img.addEventListener('error', function () { if (img.parentNode) img.parentNode.replaceChild(monoNode(t.name || t.id), img); });
img.addEventListener('error', function () { c.appendChild(img);
if (img.parentNode) img.parentNode.replaceChild(mono, img); } else {
}); c.appendChild(monoNode(t.name || t.id));
return img;
} }
// One card factory. opts.spotlight -> the roomier "Start here" treatment;
// opts.note -> a curated one-liner shown in place of the raw description.
function card(t, opts) {
opts = opts || {};
var c = el('article', opts.spotlight ? 'card spot' : 'card');
c.appendChild(logo(t));
var body = el('div', 'body'); var body = el('div', 'body');
body.appendChild(el('p', 'name', t.name || t.id)); var nameRow = el('div', 'name-row');
body.appendChild(el('p', 'desc', opts.note || t.description || '')); nameRow.appendChild(el('p', 'name', t.name || t.id));
var bt = badgeTag(t);
if (bt) nameRow.appendChild(el('span', 'cat', label(bt)));
body.appendChild(nameRow);
body.appendChild(el('p', 'desc', t.description || ''));
var row = el('div', 'row'); var row = el('div', 'row');
var deploy = el('a', 'deploy', 'Deploy'); var deploy = el('a', 'deploy', 'Deploy');
deploy.href = DEPLOY + encodeURIComponent(t.id); deploy.href = DEPLOY + encodeURIComponent(t.id);
deploy.setAttribute('aria-label', 'Deploy ' + (t.name || t.id) + ' on Hanzo');
row.appendChild(deploy); row.appendChild(deploy);
if (t.links && t.links.github) { var gh = githubUrl(t);
var gh = el('a', 'link', 'GitHub'); if (gh) {
gh.href = t.links.github; gh.target = '_blank'; gh.rel = 'noopener noreferrer'; var link = el('a', 'link', 'Source');
row.appendChild(gh); link.href = gh; link.target = '_blank'; link.rel = 'noopener noreferrer';
} row.appendChild(link);
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); body.appendChild(row);
c.appendChild(body); c.appendChild(body);
return c; return c;
} }
function inDomain(t) {
var d = DOMAP[active]; if (!d) return true;
var tags = t.tags || [];
for (var i = 0; i < tags.length; i++) if (d.set[tags[i]]) return true;
return false;
}
function counts() {
if (!resultEl) return;
var d = DOMAP[active];
var term = q && q.value.trim();
var txt;
if (term) txt = view.length + (view.length === 1 ? ' match' : ' matches');
else if (d) txt = view.length.toLocaleString() + ' in ' + d.label;
else txt = ALL.length.toLocaleString() + ' apps';
resultEl.textContent = txt;
}
function render(reset) { function render(reset) {
if (reset) { grid.textContent = ''; shown = 0; } if (reset) { grid.textContent = ''; shown = 0; }
var frag = document.createDocumentFragment(); var frag = document.createDocumentFragment();
@@ -158,125 +130,116 @@ function render(reset) {
shown = end; shown = end;
moreBtn.hidden = shown >= view.length; moreBtn.hidden = shown >= view.length;
emptyEl.hidden = view.length > 0; emptyEl.hidden = view.length > 0;
if (resultEl) {
if (!all.length) resultEl.textContent = '';
else if (view.length === all.length) resultEl.textContent = 'Showing all ' + all.length.toLocaleString() + ' apps';
else resultEl.textContent = 'Showing ' + view.length.toLocaleString() + ' of ' + all.length.toLocaleString() + ' apps';
}
} }
// The 1,030-card grid is expensive, so it is deferred: nothing renders until the
// catalog scrolls near (IntersectionObserver) or the reader searches/filters.
function apply() { function apply() {
if (io) { io.disconnect(); io = null; } var term = q.value.trim().toLowerCase();
armed = true; view = all.filter(function (t) {
var term = (q.value || '').trim().toLowerCase(); if (active && !(t.tags || []).some(function (x) { return x === active; })) return false;
view = ALL.filter(function (t) {
if (active && !inDomain(t)) return false;
if (!term) return true; if (!term) return true;
return (t.name || '').toLowerCase().indexOf(term) >= 0 || return (t.name || '').toLowerCase().indexOf(term) >= 0 ||
(t.description || '').toLowerCase().indexOf(term) >= 0 || (t.description || '').toLowerCase().indexOf(term) >= 0 ||
(t.id || '').toLowerCase().indexOf(term) >= 0 || (t.id || '').toLowerCase().indexOf(term) >= 0 ||
(t.tags || []).join(' ').toLowerCase().indexOf(term) >= 0; (t.tags || []).join(' ').toLowerCase().indexOf(term) >= 0;
}); });
counts();
render(true); render(true);
syncHash();
} }
function chip() { // ---- category chips (data-driven: recognizable first, then most common) ----
if (!chipEl) return; function chipList() {
chipEl.textContent = ''; var freq = {};
var d = DOMAP[active]; if (!d) return; all.forEach(function (t) { (t.tags || []).forEach(function (tag) { if (!HIDE[tag]) freq[tag] = (freq[tag] || 0) + 1; }); });
var b = el('button', 'chip', d.label + ' ✕'); var chosen = [], seen = {};
b.setAttribute('aria-label', 'Clear ' + d.label + ' filter'); PREFERRED.forEach(function (tag) { if (freq[tag] && !seen[tag]) { chosen.push(tag); seen[tag] = 1; } });
b.addEventListener('click', function () { setDomain(''); }); Object.keys(freq)
chipEl.appendChild(b); .filter(function (tag) { return !seen[tag]; })
.sort(function (a, b) { return freq[b] - freq[a]; })
.forEach(function (tag) { if (chosen.length < MAX_TAGS) { chosen.push(tag); seen[tag] = 1; } });
return chosen.slice(0, MAX_TAGS);
}
function setActive(tag, btn) {
if (active === tag) {
active = ''; if (btn) { btn.classList.remove('on'); btn.setAttribute('aria-pressed', 'false'); }
} else {
active = tag;
Array.prototype.forEach.call(tagsEl.children, function (c) { c.classList.remove('on'); c.setAttribute('aria-pressed', 'false'); });
if (btn) { btn.classList.add('on'); btn.setAttribute('aria-pressed', 'true'); }
} }
// The one path for browse-by-domain — domain cards and the clear-chip both call
// it. Sets the filter, reflects it, renders, and brings results into view.
function setDomain(key) {
active = (active === key) ? '' : key;
if (domainsEl) Array.prototype.forEach.call(domainsEl.children, function (c) {
c.classList.toggle('on', c.getAttribute('data-key') === active);
});
chip();
apply(); apply();
if (catalogEl) catalogEl.scrollIntoView({ block: 'start' });
} }
function buildTags() {
function renderFeatured() { chipList().forEach(function (tag) {
if (!featuredEl) return; var b = el('button', 'tag', label(tag));
var frag = document.createDocumentFragment(); b.type = 'button'; b.dataset.tag = tag; b.setAttribute('aria-pressed', 'false');
FEATURED.forEach(function (pair) { b.addEventListener('click', function () { setActive(tag, b); });
try { tagsEl.appendChild(b);
var t = BYID[pair[0]]; if (!t) return;
frag.appendChild(card(t, { spotlight: true, note: pair[1] }));
} catch (e) { /* skip one pick, never break the row */ }
}); });
featuredEl.appendChild(frag);
} }
function renderDomains() { // ---- shareable/deep-linkable state in the URL hash ----
if (!domainsEl) return; function syncHash() {
var frag = document.createDocumentFragment(); var p = [];
DOMAINS.forEach(function (d) { if (q.value.trim()) p.push('q=' + encodeURIComponent(q.value.trim()));
try { if (active) p.push('tag=' + encodeURIComponent(active));
var n = ALL.filter(function (t) { var url = p.length ? '#' + p.join('&') : location.pathname + location.search;
var tags = t.tags || []; try { history.replaceState(null, '', url); } catch (e) { /* ignore */ }
for (var i = 0; i < tags.length; i++) if (d.set[tags[i]]) return true; }
return false; function readHash() {
}).length; var h = location.hash.replace(/^#/, '');
if (!n) return; if (!h) return;
var b = el('button', 'domain'); var params = {};
b.setAttribute('data-key', d.key); h.split('&').forEach(function (kv) { var i = kv.indexOf('='); if (i > 0) params[decodeURIComponent(kv.slice(0, i))] = decodeURIComponent(kv.slice(i + 1)); });
b.appendChild(el('span', 'domain-count', n.toLocaleString())); if (params.q) q.value = params.q;
b.appendChild(el('span', 'domain-label', d.label)); if (params.tag) {
b.appendChild(el('span', 'domain-blurb', d.blurb)); active = params.tag;
b.addEventListener('click', function () { setDomain(d.key); }); Array.prototype.forEach.call(tagsEl.children, function (c) {
frag.appendChild(b); if (c.dataset.tag === active) { c.classList.add('on'); c.setAttribute('aria-pressed', 'true'); }
} catch (e) { /* skip one domain, never break the row */ }
}); });
domainsEl.appendChild(frag); }
} }
function fail(msg) { function fail(msg) { if (emptyEl) { emptyEl.hidden = false; emptyEl.textContent = msg; } if (resultEl) resultEl.textContent = ''; }
if (emptyEl) { emptyEl.hidden = false; emptyEl.textContent = msg; }
if (resultEl) resultEl.textContent = '';
}
function start() { function start() {
grid = $('grid'); q = $('q'); moreBtn = $('more'); emptyEl = $('empty'); grid = document.getElementById('grid'); q = document.getElementById('q');
countEl = $('count'); featuredEl = $('featured'); domainsEl = $('domains'); tagsEl = document.getElementById('tags'); moreBtn = document.getElementById('more');
chipEl = $('active-filter'); resultEl = $('result-count'); emptyEl = document.getElementById('empty'); resultEl = document.getElementById('result-count');
catalogEl = document.querySelector('.catalog'); if (!grid || !q) return;
if (!grid) return;
moreBtn.addEventListener('click', function () { render(false); }); moreBtn.addEventListener('click', function () { render(false); });
var timer; var timer;
q.addEventListener('input', function () { clearTimeout(timer); timer = setTimeout(apply, 80); }); q.addEventListener('input', function () { clearTimeout(timer); timer = setTimeout(apply, 80); });
document.addEventListener('keydown', function (e) {
fetch('/meta.json', { cache: 'default' }) var elx = document.activeElement, typing = elx && (elx.tagName === 'INPUT' || elx.tagName === 'TEXTAREA');
.then(function (r) { if (!r.ok) throw new Error(r.status); return r.json(); }) if (e.key === '/' && !typing) { e.preventDefault(); q.focus(); q.select(); }
.then(function (data) { else if (e.key === 'Escape') {
ALL = (Array.isArray(data) ? data : []).filter(function (t) { return t && t.id; }); if (q.value || active) {
if (!ALL.length) return fail('Catalog is empty.'); q.value = ''; active = '';
ALL.forEach(function (t) { BYID[t.id] = t; }); Array.prototype.forEach.call(tagsEl.children, function (c) { c.classList.remove('on'); c.setAttribute('aria-pressed', 'false'); });
if (countEl) countEl.textContent = ALL.length.toLocaleString();
renderFeatured();
renderDomains();
view = ALL;
counts();
// Arm the deferred grid: render when it scrolls near, or on first search.
if ('IntersectionObserver' in window && catalogEl) {
io = new IntersectionObserver(function (entries) {
if (entries.some(function (e) { return e.isIntersecting; })) apply();
}, { rootMargin: '400px 0px' });
io.observe(catalogEl);
} else {
apply(); apply();
} }
if (typing) elx.blur();
}
});
fetch(CATALOG, { 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.');
var counts = document.querySelectorAll('#count, #count-2');
Array.prototype.forEach.call(counts, function (n) { n.textContent = all.length.toLocaleString(); });
buildTags();
readHash();
apply();
}) })
.catch(function () { fail('Catalog failed to load. Try /meta.json directly.'); }); .catch(function () { fail('Catalog failed to load — open /meta.json directly.'); });
} }
if (document.readyState === 'loading') { onReady(start);
document.addEventListener('DOMContentLoaded', start);
} else {
start();
}
+196 -158
View File
@@ -1,234 +1,272 @@
/* oss.hanzo.ai — the open-source explorer. The apple of open source: /* oss.hanzo.ai — the open-source cloud storefront.
monochrome, Geist, true black, one accent (the white Deploy button). One page: the editorial story (hero · beliefs · makers) above a searchable
Restraint, rhythm, motion with taste. Every rule earns its place. */ 1,030-app launcher. One stylesheet. Theme-aware (light default, dark via the
OS or an explicit [data-theme] toggle). Responsive 390 → 768 → 1280 → 1680,
no horizontal body scroll. CSP-strict: no external font, no external CSS, no
inline script. Reads the same /meta.json + /blueprints/<id>/ the PaaS loader
consumes — the machine catalog is untouched. */
/* ---- design tokens: light default, dark via OS + [data-theme] override ---- */
:root { :root {
--bg: #000; --panel: #0a0a0b; --panel-hi: #101013; --bg: #ffffff; --panel: #ffffff; --panel-2: #f7f7f8;
--line: #191a1d; --line-hi: #2a2a30; --line: #e7e7ea; --line-2: #d7d7dc;
--fg: #fafafa; --muted: #8d8d95; --faint: #5a5a61; --fg: #0a0a0b; --muted: #62626a; --faint: #9a9aa2;
--btn-bg: #0a0a0b; --btn-fg: #ffffff; --btn-bg-hover: #262629;
--logo-bg: #ffffff; --logo-ring: #ececef;
--ring: rgba(10, 10, 11, 0.14);
--shadow: 0 1px 2px rgba(10, 10, 11, 0.05), 0 8px 24px rgba(10, 10, 11, 0.04);
--shadow-hover: 0 2px 6px rgba(10, 10, 11, 0.08), 0 14px 40px rgba(10, 10, 11, 0.08);
--bar-bg: rgba(255, 255, 255, 0.72);
--radius: 14px; --radius: 14px;
--top-h: 3.5rem; --top-h: 3.5rem;
--ease: cubic-bezier(0.22, 1, 0.36, 1); --ease: cubic-bezier(0.22, 1, 0.36, 1);
color-scheme: light;
} }
@media (prefers-color-scheme: dark) {
:root {
--bg: #000000; --panel: #0d0d0f; --panel-2: #08080a;
--line: #1d1d21; --line-2: #2c2c31;
--fg: #f6f6f7; --muted: #8c8c93; --faint: #5b5b62;
--btn-bg: #f6f6f7; --btn-fg: #0a0a0b; --btn-bg-hover: #d6d6d8;
--logo-bg: #ffffff; --logo-ring: #26262b;
--ring: rgba(246, 246, 247, 0.16);
--shadow: none; --shadow-hover: 0 0 0 1px var(--line-2);
--bar-bg: rgba(0, 0, 0, 0.72);
color-scheme: dark;
}
}
/* an explicit toggle wins over the OS setting, in both directions */
:root[data-theme="light"] {
--bg: #ffffff; --panel: #ffffff; --panel-2: #f7f7f8;
--line: #e7e7ea; --line-2: #d7d7dc; --fg: #0a0a0b; --muted: #62626a; --faint: #9a9aa2;
--btn-bg: #0a0a0b; --btn-fg: #ffffff; --btn-bg-hover: #262629;
--logo-bg: #ffffff; --logo-ring: #ececef; --ring: rgba(10, 10, 11, 0.14);
--shadow: 0 1px 2px rgba(10, 10, 11, 0.05), 0 8px 24px rgba(10, 10, 11, 0.04);
--shadow-hover: 0 2px 6px rgba(10, 10, 11, 0.08), 0 14px 40px rgba(10, 10, 11, 0.08);
--bar-bg: rgba(255, 255, 255, 0.72); color-scheme: light;
}
:root[data-theme="dark"] {
--bg: #000000; --panel: #0d0d0f; --panel-2: #08080a;
--line: #1d1d21; --line-2: #2c2c31; --fg: #f6f6f7; --muted: #8c8c93; --faint: #5b5b62;
--btn-bg: #f6f6f7; --btn-fg: #0a0a0b; --btn-bg-hover: #d6d6d8;
--logo-bg: #ffffff; --logo-ring: #26262b; --ring: rgba(246, 246, 247, 0.16);
--shadow: none; --shadow-hover: 0 0 0 1px var(--line-2);
--bar-bg: rgba(0, 0, 0, 0.72); color-scheme: dark;
}
* { box-sizing: border-box; } * { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; } html { -webkit-text-size-adjust: 100%; }
@media (prefers-reduced-motion: no-preference) { html { scroll-behavior: smooth; } } @media (prefers-reduced-motion: no-preference) { html { scroll-behavior: smooth; } }
html, body { margin: 0; padding: 0; }
body { body {
margin: 0; background: var(--bg); color: var(--fg); background: var(--bg); color: var(--fg); overflow-x: hidden;
font: 15px/1.55 ui-sans-serif, -apple-system, "Geist", "Inter", "Segoe UI", Roboto, system-ui, sans-serif; font: 15px/1.55 ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji", sans-serif;
-webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility;
} }
a { color: inherit; text-decoration: none; } a { color: inherit; text-decoration: none; }
button { font: inherit; }
img { max-width: 100%; }
h1, h2, h3 { margin: 0; font-weight: 600; letter-spacing: -0.03em; } h1, h2, h3 { margin: 0; font-weight: 600; letter-spacing: -0.03em; }
::selection { background: #fff; color: #000; } ::selection { background: var(--fg); color: var(--bg); }
:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; border-radius: 6px; }
.eyebrow { .eyebrow {
margin: 0 0 1rem; font-size: .74rem; font-weight: 500; letter-spacing: .16em; margin: 0 0 1rem; font-size: 0.74rem; font-weight: 500; letter-spacing: 0.16em;
text-transform: uppercase; color: var(--faint); text-transform: uppercase; color: var(--faint);
} }
/* ---- top bar --------------------------------------------------------- */ /* ---------------------------------------------------------- header / nav ---- */
.top { .top {
position: sticky; top: 0; z-index: 30; height: var(--top-h); position: sticky; top: 0; z-index: 30; height: var(--top-h);
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between; gap: 1rem;
padding: 0 1.75rem; padding: 0 1.5rem; border-bottom: 1px solid var(--line);
background: rgba(0,0,0,.62); backdrop-filter: saturate(180%) blur(16px); background: var(--bar-bg);
border-bottom: 1px solid var(--line); backdrop-filter: saturate(1.8) blur(14px); -webkit-backdrop-filter: saturate(1.8) blur(14px);
} }
.brand { display: inline-flex; align-items: center; gap: .55rem; font-weight: 600; letter-spacing: -0.03em; font-size: 1.02rem; } .brand { display: inline-flex; align-items: center; gap: 0.55rem; font-weight: 600; letter-spacing: -0.02em; font-size: 1.02rem; }
.mark { .brand .mark { width: 1.25rem; height: 1.25rem; display: block; border-radius: 5px; color: var(--fg); }
width: 1.15rem; height: 1.15rem; border-radius: 5px; flex: 0 0 auto; .brand .sub { color: var(--muted); font-weight: 500; }
background: #fff url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2032%2032'%3E%3Cg%20fill='%23000'%3E%3Crect%20x='9'%20y='8'%20width='3.6'%20height='16'/%3E%3Crect%20x='19.4'%20y='8'%20width='3.6'%20height='16'/%3E%3Crect%20x='9'%20y='14.2'%20width='14'%20height='3.6'/%3E%3C/g%3E%3C/svg%3E") center/72% no-repeat; .topnav { display: flex; align-items: center; gap: 0.35rem; }
.topnav a {
color: var(--muted); font-size: 0.9rem; padding: 0.4rem 0.7rem; border-radius: 8px;
transition: color 0.15s var(--ease), background 0.15s var(--ease);
}
.topnav a:hover { color: var(--fg); background: var(--panel-2); }
.topnav .cta { color: var(--btn-fg); background: var(--btn-bg); font-weight: 500; }
.topnav .cta:hover { color: var(--btn-fg); background: var(--btn-bg-hover); }
.icon-btn {
display: inline-grid; place-items: center; width: 34px; height: 34px;
border: 1px solid var(--line); border-radius: 8px; background: transparent;
color: var(--muted); cursor: pointer; transition: color 0.15s var(--ease), border-color 0.15s var(--ease);
}
.icon-btn:hover { color: var(--fg); border-color: var(--line-2); }
.icon-btn svg { width: 16px; height: 16px; }
/* sun in dark (click → light), moon in light (click → dark) */
.icon-btn .moon { display: none; }
:root[data-theme="dark"] .icon-btn .sun { display: block; }
:root[data-theme="dark"] .icon-btn .moon { display: none; }
:root[data-theme="light"] .icon-btn .sun { display: block; }
:root[data-theme="light"] .icon-btn .moon { display: none; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .icon-btn .sun { display: none; }
:root:not([data-theme="light"]) .icon-btn .moon { display: block; }
} }
.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 ------- */
.hero { max-width: 58rem; margin: 0 auto; padding: 5.5rem 1.5rem 3.5rem; text-align: center; } .hero { max-width: 58rem; margin: 0 auto; padding: 5rem 1.5rem 3.5rem; text-align: center; }
.hero h1 { .hero h1 {
margin: 0 0 1.25rem; font-size: clamp(2.5rem, 7vw, 4.75rem); line-height: 1.02; margin: 0 0 1.25rem; font-size: clamp(2.4rem, 6.4vw, 4.5rem); line-height: 1.03; letter-spacing: -0.045em;
letter-spacing: -0.045em;
} }
.hero h1 .dim { color: var(--muted); } .hero h1 .dim { color: var(--muted); }
.lead { margin: 0 auto 2rem; max-width: 38rem; font-size: clamp(1.05rem, 2.2vw, 1.28rem); color: var(--muted); } .lead { margin: 0 auto 2rem; max-width: 39rem; font-size: clamp(1.02rem, 2.2vw, 1.24rem); color: var(--muted); }
.lead strong { color: var(--fg); font-weight: 600; } .lead strong { color: var(--fg); font-weight: 600; }
.cta { display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap; }
.cta { display: flex; gap: .75rem; justify-content: center; flex-wrap: wrap; }
.btn { .btn {
display: inline-flex; align-items: center; padding: .72rem 1.4rem; display: inline-flex; align-items: center; padding: 0.72rem 1.4rem;
font-size: .92rem; font-weight: 600; border-radius: 10px; font-size: 0.92rem; font-weight: 600; border-radius: 10px; border: 1px solid transparent; cursor: pointer;
border: 1px solid transparent; cursor: pointer; transition: background 0.16s var(--ease), border-color 0.16s var(--ease), color 0.16s var(--ease), transform 0.16s var(--ease);
transition: background .16s var(--ease), border-color .16s var(--ease), color .16s var(--ease), transform .16s var(--ease);
} }
.btn.primary { color: #000; background: var(--fg); } .btn.primary { color: var(--btn-fg); background: var(--btn-bg); }
.btn.primary:hover { background: #fff; transform: translateY(-1px); } .btn.primary:hover { background: var(--btn-bg-hover); transform: translateY(-1px); }
.btn.ghost { color: var(--fg); border-color: var(--line-hi); background: transparent; } .btn.ghost { color: var(--fg); border-color: var(--line-2); background: transparent; }
.btn.ghost:hover { border-color: #555; background: var(--panel); } .btn.ghost:hover { border-color: var(--muted); background: var(--panel-2); }
/* ---- philosophy band ------------------------------------------------- */ /* ---- philosophy band ------------------------------------------------------ */
.beliefs { .beliefs {
max-width: 78rem; margin: 0 auto; padding: 3rem 1.5rem 4rem; max-width: 78rem; margin: 0 auto; padding: 3rem 1.5rem 4rem;
border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); border-top: 1px solid var(--line); border-bottom: 1px solid var(--line);
} }
.belt { display: grid; gap: 2rem; grid-template-columns: repeat(3, 1fr); } .belt { display: grid; gap: 2rem; grid-template-columns: repeat(3, 1fr); }
.belief h3 { font-size: 1.16rem; letter-spacing: -0.02em; margin-bottom: .5rem; } .belief h3 { font-size: 1.16rem; letter-spacing: -0.02em; margin-bottom: 0.5rem; }
.belief p { margin: 0; color: var(--muted); font-size: .95rem; line-height: 1.55; } .belief p { margin: 0; color: var(--muted); font-size: 0.95rem; line-height: 1.55; }
.signature { margin: 2.75rem 0 0; text-align: center; font-size: .84rem; color: var(--faint); } .signature { margin: 2.75rem 0 0; text-align: center; font-size: 0.84rem; color: var(--faint); }
/* ---- makers band ----------------------------------------------------- */ /* ---- makers band ---------------------------------------------------------- */
.makers { .makers { max-width: 46rem; margin: 0 auto; padding: 5rem 1.5rem; text-align: center; scroll-margin-top: var(--top-h); }
max-width: 46rem; margin: 0 auto; padding: 5rem 1.5rem; text-align: center;
scroll-margin-top: var(--top-h);
}
.makers h2 { font-size: clamp(1.9rem, 4.5vw, 2.9rem); letter-spacing: -0.04em; margin-bottom: 1.1rem; } .makers h2 { font-size: clamp(1.9rem, 4.5vw, 2.9rem); letter-spacing: -0.04em; margin-bottom: 1.1rem; }
.makers-lead { margin: 0 auto 2.25rem; max-width: 34rem; font-size: 1.08rem; color: var(--muted); } .makers-lead { margin: 0 auto 2.25rem; max-width: 35rem; font-size: 1.08rem; color: var(--muted); }
.makers-lead strong { color: var(--fg); font-weight: 600; } .makers-lead strong { color: var(--fg); font-weight: 600; }
.steps { .steps { list-style: none; margin: 0 0 2.5rem; padding: 0; display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap; }
list-style: none; margin: 0 0 2.5rem; padding: 0;
display: flex; gap: .75rem; justify-content: center; flex-wrap: wrap;
}
.steps li { .steps li {
display: flex; align-items: center; gap: .6rem; display: flex; align-items: center; gap: 0.6rem; padding: 0.7rem 1.15rem; font-size: 0.9rem; color: var(--fg);
padding: .7rem 1.15rem; font-size: .9rem; color: var(--fg);
background: var(--panel); border: 1px solid var(--line); border-radius: 999px; background: var(--panel); border: 1px solid var(--line); border-radius: 999px;
} }
.step-n { font-size: .72rem; font-weight: 600; color: var(--faint); letter-spacing: .04em; } .step-n { font-size: 0.72rem; font-weight: 600; color: var(--faint); letter-spacing: 0.04em; }
/* ---- explore: shared section heads ----------------------------------- */ /* ---- explore: section head ------------------------------------------------ */
main { max-width: 84rem; margin: 0 auto; padding: 0 1.5rem 5rem; } main { max-width: 84rem; margin: 0 auto; padding: 0 1.5rem 4rem; scroll-margin-top: var(--top-h); }
.section-head { margin: 3.5rem 0 1.5rem; } .section-head { max-width: 58rem; margin: 3.5rem auto 1.75rem; text-align: center; }
.section-head h2 { font-size: clamp(1.5rem, 3.5vw, 2.1rem); letter-spacing: -0.035em; } .section-head h2 { font-size: clamp(1.6rem, 3.6vw, 2.2rem); letter-spacing: -0.035em; }
.section-head p { margin: .5rem 0 0; color: var(--muted); font-size: 1rem; } .section-head p { margin: 0.5rem 0 0; color: var(--muted); font-size: 1rem; }
/* ---- curated: "Start here" spotlight cards --------------------------- */ /* ---- search + category chips ---------------------------------------------- */
.featured { display: grid; gap: .85rem; grid-template-columns: repeat(auto-fill, minmax(21.5rem, 1fr)); } .search-wrap { position: relative; max-width: 40rem; margin: 0 auto; }
.search-wrap .glass {
/* ---- browse: domain cards -------------------------------------------- */ position: absolute; left: 1rem; top: 50%; transform: translateY(-50%);
.domains { display: grid; gap: .85rem; grid-template-columns: repeat(auto-fill, minmax(17.5rem, 1fr)); } width: 18px; height: 18px; color: var(--faint); pointer-events: none;
.domain {
display: flex; flex-direction: column; align-items: flex-start; gap: .3rem;
padding: 1.25rem 1.3rem; text-align: left; cursor: pointer;
background: var(--panel); color: var(--fg);
border: 1px solid var(--line); border-radius: var(--radius);
transition: border-color .16s var(--ease), background .16s var(--ease), transform .16s var(--ease);
}
.domain:hover { border-color: var(--line-hi); background: var(--panel-hi); transform: translateY(-2px); }
.domain.on { border-color: var(--fg); background: var(--panel-hi); }
.domain-count { font-size: 1.7rem; font-weight: 600; letter-spacing: -0.03em; line-height: 1; }
.domain-label { font-size: .95rem; font-weight: 600; }
.domain-blurb { font-size: .84rem; color: var(--muted); line-height: 1.5; }
.domain.on .domain-count { color: var(--fg); }
/* ---- catalog toolbar (sticky) ---------------------------------------- */
.catalog { scroll-margin-top: var(--top-h); }
.toolbar {
position: sticky; top: var(--top-h); z-index: 20;
display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;
margin: 3.5rem 0 1.25rem; padding: .9rem 0;
background: rgba(0,0,0,.72); backdrop-filter: saturate(180%) blur(14px);
} }
.search { .search {
flex: 1 1 22rem; min-width: 0; width: 100%; display: block; background: var(--panel); border: 1px solid var(--line-2); border-radius: 12px;
padding: .85rem 1.15rem; font-size: 1rem; color: var(--fg); color: var(--fg); font-size: 1rem; padding: 0.9rem 2.9rem 0.9rem 2.85rem; outline: none;
background: var(--panel); border: 1px solid var(--line-hi); border-radius: 11px; box-shadow: var(--shadow); transition: border-color 0.15s var(--ease), box-shadow 0.15s var(--ease);
outline: none; transition: border-color .15s var(--ease), background .15s var(--ease);
} }
.search:focus { border-color: var(--fg); box-shadow: 0 0 0 4px var(--ring); }
.search::placeholder { color: var(--faint); } .search::placeholder { color: var(--faint); }
.search:focus { border-color: #555; background: var(--panel-hi); } .kbd {
.toolbar-meta { display: flex; align-items: center; gap: .75rem; margin-left: auto; } position: absolute; right: 0.75rem; top: 50%; transform: translateY(-50%);
.chip { font-size: 0.72rem; color: var(--faint); border: 1px solid var(--line);
padding: .4rem .8rem; font-size: .82rem; font-weight: 500; color: #000; border-radius: 6px; padding: 0.1rem 0.4rem; background: var(--panel-2);
background: var(--fg); border: 1px solid var(--fg); border-radius: 999px; cursor: pointer;
transition: opacity .15s var(--ease);
} }
.chip:hover { opacity: .82; } .result-count { color: var(--faint); font-size: 0.82rem; margin: 0.9rem 0 0; min-height: 1.1em; text-align: center; }
.result-count { font-size: .85rem; color: var(--muted); white-space: nowrap; } .tags { display: flex; flex-wrap: wrap; gap: 0.45rem; justify-content: center; margin: 1rem auto 0; max-width: 52rem; }
.tag {
font-size: 0.8rem; color: var(--muted); background: var(--panel); border: 1px solid var(--line);
border-radius: 999px; padding: 0.32rem 0.85rem; cursor: pointer; white-space: nowrap;
transition: color 0.15s var(--ease), border-color 0.15s var(--ease), background 0.15s var(--ease);
}
.tag:hover { color: var(--fg); border-color: var(--line-2); }
.tag.on { color: var(--btn-fg); background: var(--btn-bg); border-color: var(--btn-bg); }
/* ---- the grid -------------------------------------------------------- */ /* ---- the grid ------------------------------------------------------------- */
.grid { display: grid; gap: .85rem; grid-template-columns: repeat(auto-fill, minmax(19.5rem, 1fr)); } .grid { display: grid; gap: 0.8rem; grid-template-columns: repeat(auto-fill, minmax(18.5rem, 1fr)); margin-top: 2rem; }
.card { .card {
content-visibility: auto; contain-intrinsic-size: 0 116px; content-visibility: auto; contain-intrinsic-size: 0 118px;
display: flex; gap: .9rem; align-items: flex-start; display: flex; gap: 0.9rem; align-items: flex-start; padding: 1rem;
padding: 1.1rem; background: var(--panel); background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow);
border: 1px solid var(--line); border-radius: var(--radius); transition: border-color 0.16s var(--ease), box-shadow 0.16s var(--ease), transform 0.16s var(--ease);
transition: border-color .16s var(--ease), background .16s var(--ease), transform .16s var(--ease);
} }
.card:hover { border-color: var(--line-hi); background: var(--panel-hi); transform: translateY(-2px); } .card:hover { border-color: var(--line-2); box-shadow: var(--shadow-hover); transform: translateY(-1px); }
.card.spot { content-visibility: visible; padding: 1.35rem; }
.logo { .logo {
flex: 0 0 42px; width: 42px; height: 42px; border-radius: 10px; flex: 0 0 42px; width: 42px; height: 42px; border-radius: 10px;
object-fit: contain; background: #fff; padding: 5px; background: var(--logo-bg); box-shadow: inset 0 0 0 1px var(--logo-ring);
object-fit: contain; padding: 6px; display: grid; place-items: center; overflow: hidden;
} }
.card.spot .logo { flex-basis: 48px; width: 48px; height: 48px; } .logo.mono { background: var(--panel-2); box-shadow: inset 0 0 0 1px var(--line); color: var(--fg); font-weight: 650; font-size: 1.15rem; }
.logo.mono { .body { min-width: 0; flex: 1; }
display: grid; place-items: center; padding: 0; .name-row { display: flex; align-items: baseline; gap: 0.5rem; }
background: var(--line-hi); color: var(--fg); font-weight: 600; font-size: 1.15rem; .name { font-weight: 600; letter-spacing: -0.015em; margin: 0; font-size: 0.98rem; }
.cat {
font-size: 0.68rem; color: var(--muted); border: 1px solid var(--line); border-radius: 999px;
padding: 0.05rem 0.5rem; white-space: nowrap; text-transform: capitalize;
} }
.body { flex: 1; min-width: 0; }
.name { margin: 0; font-size: 1rem; font-weight: 600; letter-spacing: -0.015em; }
.card.spot .name { font-size: 1.08rem; }
.desc { .desc {
margin: .25rem 0 .75rem; font-size: .84rem; color: var(--muted); line-height: 1.48; color: var(--muted); font-size: 0.83rem; margin: 0.28rem 0 0.7rem; line-height: 1.5;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
} }
.card.spot .desc { color: #b4b4bb; } .row { display: flex; align-items: center; gap: 0.55rem; flex-wrap: wrap; }
.row { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
.deploy { .deploy {
padding: .36rem .85rem; font-size: .8rem; font-weight: 600; color: #000; display: inline-flex; align-items: center; gap: 0.35rem; font-size: 0.82rem; font-weight: 550;
background: var(--fg); border-radius: 8px; transition: background .15s var(--ease), transform .15s var(--ease); color: var(--btn-fg); background: var(--btn-bg); border-radius: 8px; padding: 0.36rem 0.75rem;
transition: background 0.15s var(--ease), transform 0.15s var(--ease);
} }
.deploy:hover { background: #fff; transform: translateY(-1px); } .deploy:hover { background: var(--btn-bg-hover); color: var(--btn-fg); transform: translateY(-1px); }
.link { font-size: .8rem; color: var(--muted); transition: color .15s var(--ease); } .link { font-size: 0.8rem; color: var(--muted); padding: 0.36rem 0.4rem; transition: color 0.15s var(--ease); }
.link:hover { color: var(--fg); } .link:hover { color: var(--fg); }
.claim { margin-left: auto; font-size: .76rem; color: var(--faint); transition: color .15s var(--ease); }
.claim:hover { color: var(--fg); }
.more { .more {
display: block; margin: 2.5rem auto 0; padding: .75rem 1.75rem; display: block; margin: 2.5rem auto 0; background: var(--panel); color: var(--fg);
font-size: .9rem; color: var(--fg); background: var(--panel); border: 1px solid var(--line-2); border-radius: 10px; padding: 0.75rem 1.75rem; cursor: pointer; font-size: 0.9rem;
border: 1px solid var(--line-hi); border-radius: 10px; cursor: pointer; transition: border-color 0.15s var(--ease), background 0.15s var(--ease);
transition: border-color .15s var(--ease);
} }
.more:hover { border-color: #555; } .more:hover { border-color: var(--fg); background: var(--panel-2); }
.empty { padding: 4rem 0; text-align: center; color: var(--muted); } .empty { text-align: center; color: var(--muted); padding: 4rem 1rem; font-size: 0.95rem; }
/* ---- footer ---------------------------------------------------------- */ /* ---- footer --------------------------------------------------------------- */
.foot { .foot {
max-width: 84rem; margin: 0 auto; padding: 2.5rem 1.5rem; max-width: 84rem; margin: 0 auto; padding: 2.5rem 1.5rem 3.5rem; border-top: 1px solid var(--line);
display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap; display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap; align-items: center;
border-top: 1px solid var(--line); color: var(--faint); font-size: .8rem; color: var(--faint); font-size: 0.8rem;
} }
.foot a { color: var(--muted); } .foot a { color: var(--muted); border-bottom: 1px solid transparent; }
.foot a:hover { color: var(--fg); } .foot a:hover { color: var(--fg); border-bottom-color: var(--line-2); }
.foot .links { display: flex; gap: 1.1rem; flex-wrap: wrap; }
/* ---- responsive ------------------------------------------------------ */ /* ---- responsive ----------------------------------------------------------- */
@media (max-width: 1024px) { .belt { gap: 1.5rem; } }
@media (max-width: 760px) { @media (max-width: 760px) {
.top { padding: 0 1.15rem; } .top { padding: 0 1.15rem; }
.belt { grid-template-columns: 1fr; gap: 1.5rem; } .belt { grid-template-columns: 1fr; gap: 1.5rem; }
.hero { padding: 3.5rem 1.25rem 2.5rem; } .hero { padding: 3.5rem 1.25rem 2.5rem; }
.toolbar-meta { margin-left: 0; width: 100%; justify-content: space-between; } .makers { padding: 3.5rem 1.25rem; }
.search { flex-basis: 100%; } .topnav .hide-sm { display: none; }
}
@media (max-width: 460px) {
.brand .sub { display: none; }
.kbd { display: none; }
.grid { grid-template-columns: 1fr; }
}
@media (min-width: 1600px) {
main, .foot { max-width: 90rem; }
.grid { grid-template-columns: repeat(auto-fill, minmax(19.5rem, 1fr)); }
} }
/* ---- motion with taste ----------------------------------------------- */ /* ---- motion: hero rises once on load (always ends visible) ----------------- */
/* Hero rises once on load — always ends visible, so nothing can hide it. */
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
.hero > * { animation: rise .6s var(--ease) both; } .hero > * { animation: rise 0.6s var(--ease) both; }
.hero .eyebrow { animation-delay: .02s; } .hero .eyebrow { animation-delay: 0.02s; }
.hero h1 { animation-delay: .08s; } .hero h1 { animation-delay: 0.08s; }
.hero .lead { animation-delay: .16s; } .hero .lead { animation-delay: 0.16s; }
.hero .cta { animation-delay: .24s; } .hero .cta { animation-delay: 0.24s; }
} }
@keyframes rise { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: none; } } @keyframes rise { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: none; } }
/* Below-the-fold reveal — opt-in ONLY where scroll timelines exist, so
unsupported browsers (and reduced-motion) always render fully visible. */
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
.belief, .section-head, .makers > *, .featured > .card, .domains > .domain {
opacity: 0; animation: rise linear both;
animation-timeline: view(); animation-range: entry 0% cover 20%;
}
}
}
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none !important; animation: none !important; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none !important; animation: none !important; } }
+36 -35
View File
@@ -4,19 +4,28 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Hanzo OSS — deploy the open-source internet in one click</title> <title>Hanzo OSS — deploy the open-source internet in one click</title>
<meta name="description" content="The world runs on open source. Deploy any of 1,030 apps — Postgres, Ghost, n8n, Grafana, Ollama — in one click, on your machine or Hanzo Cloud. Build one, and earn 20% when the world runs it." /> <meta name="description" content="The world runs on open source. Browse and one-click deploy 1,030 apps — Postgres, Ghost, n8n, Grafana, Ollama — on your machine or Hanzo Cloud. Build one, and earn a share when the world runs it." />
<meta name="color-scheme" content="light dark" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%230a0a0b'/%3E%3Cpath d='M10 8v16M22 8v16M10 16h12' stroke='%23fff' stroke-width='2.4' stroke-linecap='round'/%3E%3C/svg%3E" />
<link rel="stylesheet" href="/catalog.css" /> <link rel="stylesheet" href="/catalog.css" />
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2032%2032'%3E%3Crect%20width='32'%20height='32'%20rx='7'%20fill='%23000'/%3E%3Cg%20fill='%23fff'%3E%3Crect%20x='9'%20y='8'%20width='3.6'%20height='16'/%3E%3Crect%20x='19.4'%20y='8'%20width='3.6'%20height='16'/%3E%3Crect%20x='9'%20y='14.2'%20width='14'%20height='3.6'/%3E%3C/g%3E%3C/svg%3E" /> <script src="/app.js"></script>
</head> </head>
<body> <body>
<!-- header -->
<header class="top"> <header class="top">
<a class="brand" href="https://hanzo.ai" aria-label="Hanzo"> <a class="brand" href="https://hanzo.ai" aria-label="Hanzo">
<span class="mark" aria-hidden="true"></span>Hanzo <svg class="mark" viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="7" fill="currentColor"/><path d="M10 8v16M22 8v16M10 16h12" stroke="var(--bg)" stroke-width="2.4" stroke-linecap="round"/></svg>
<span>Hanzo <span class="sub">OSS</span></span>
</a> </a>
<nav class="topnav"> <nav class="topnav" aria-label="Primary">
<a href="#explore">Explore</a> <a class="hide-sm" href="#explore">Explore</a>
<a href="#makers">Earn</a> <a class="hide-sm" href="#makers">Earn</a>
<a href="https://docs.hanzo.ai">Docs</a> <a class="hide-sm" href="https://docs.hanzo.ai" target="_blank" rel="noopener">Docs</a>
<a class="cta" href="https://platform.hanzo.ai" target="_blank" rel="noopener">Deploy console</a>
<button id="theme-toggle" class="icon-btn" type="button" aria-label="Toggle color theme" title="Toggle theme">
<svg class="sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
<svg class="moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
</button>
</nav> </nav>
</header> </header>
@@ -48,7 +57,7 @@
</article> </article>
<article class="belief"> <article class="belief">
<h3>Earn when others run it.</h3> <h3>Earn when others run it.</h3>
<p>Ship a template, keep 20% of the compute it earns. Open source that pays its makers.</p> <p>Ship a template, and keep a share of the compute it earns — paid out automatically. Open source that pays its makers.</p>
</article> </article>
</div> </div>
<p class="signature">Open by default. Building in the open since Techstars&nbsp;'17.</p> <p class="signature">Open by default. Building in the open since Techstars&nbsp;'17.</p>
@@ -59,8 +68,8 @@
<p class="eyebrow">For makers</p> <p class="eyebrow">For makers</p>
<h2>Open source that pays.</h2> <h2>Open source that pays.</h2>
<p class="makers-lead"> <p class="makers-lead">
Every app here is somebody's work. When your project runs on Hanzo you keep Every app here is somebody's work. When your project runs on Hanzo you keep a
<strong>20% of the compute revenue</strong> — metered per second, paid out automatically. <strong>share of the compute it earns</strong> — metered per second, paid out automatically.
No invoices. No gatekeepers. No invoices. No gatekeepers.
</p> </p>
<ol class="steps"> <ol class="steps">
@@ -71,43 +80,35 @@
<a class="btn primary" href="https://console.hanzo.ai/authors">Claim your project &rarr;</a> <a class="btn primary" href="https://console.hanzo.ai/authors">Claim your project &rarr;</a>
</section> </section>
<!-- EXPLORE — curated first, then the full searchable catalog. JS-rendered from /meta.json. --> <!-- EXPLORE — the searchable 1,030-app launcher. JS-rendered from /meta.json. -->
<main id="explore"> <main id="explore">
<section class="curated" aria-label="Start here">
<div class="section-head"> <div class="section-head">
<h2>Start here</h2> <h2>Explore the catalog</h2>
<p>The ones everyone deploys first.</p> <p>Search <span id="count-2">1,030</span> open-source apps, or filter by category — one click sends any of them to Hanzo Cloud.</p>
</div> </div>
<div id="featured" class="featured"></div>
</section>
<section class="browse" aria-label="Browse by domain"> <div class="search-wrap">
<div class="section-head"> <svg class="glass" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
<h2>Browse by what you want to do</h2> <input id="q" class="search" type="search" placeholder="Search apps — postgres, ghost, n8n, grafana, ollama…" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Search open-source apps" />
<p>Eight domains. Pick a lane — the catalog follows.</p> <span class="kbd" aria-hidden="true">/</span>
</div> </div>
<div id="domains" class="domains"></div> <p class="result-count" id="result-count" role="status" aria-live="polite"></p>
</section> <div class="tags" id="tags" role="group" aria-label="Filter by category"></div>
<section class="catalog" aria-label="All apps"> <div id="grid" class="grid" aria-label="Open-source apps" aria-live="polite"></div>
<div class="toolbar">
<input id="q" class="search" type="search" placeholder="Search 1,030 apps — postgres, ghost, n8n, grafana…" autocomplete="off" aria-label="Search apps" />
<div class="toolbar-meta">
<span id="active-filter" class="chip-slot"></span>
<span id="result-count" class="result-count"></span>
</div>
</div>
<div id="grid" class="grid" aria-live="polite"></div>
<p id="empty" class="empty" hidden>No apps match — try another term.</p> <p id="empty" class="empty" hidden>No apps match — try another term.</p>
<button id="more" class="more" hidden>Load more</button> <button id="more" class="more" type="button" hidden>Load more</button>
</section>
</main> </main>
<!-- footer -->
<footer class="foot"> <footer class="foot">
<span>Sourced from the open-source Dokploy, Coolify, CapRover, CasaOS &amp; Runtipi catalogs — each app keeps its upstream license.</span> <span>Sourced from the open-source Dokploy, Coolify, CapRover, CasaOS &amp; Runtipi catalogs — each app keeps its upstream license.</span>
<span class="links">
<a href="/meta.json">Catalog API</a> <a href="/meta.json">Catalog API</a>
<a href="https://platform.hanzo.ai" target="_blank" rel="noopener">Hanzo PaaS</a>
<a href="https://docs.hanzo.ai" target="_blank" rel="noopener">Docs</a>
<a href="https://github.com/hanzoai" target="_blank" rel="noopener">GitHub</a>
</span>
</footer> </footer>
<script src="/app.js" defer></script>
</body> </body>
</html> </html>