mirror of
https://github.com/hanzoai/templates-blueprints.git
synced 2026-10-01 00:43:09 +00:00
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:
@@ -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
|
||||
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
|
||||
never touch the contract above (meta.json / blueprints are read-only to them).
|
||||
`static` serves a strict CSP: `script-src 'self'` (external app.js, NO inline
|
||||
`<script>` / handlers), `img-src 'self' data:` (logos same-origin, favicon +
|
||||
brand mark are inline data: SVGs — NEVER an external favicon URL), `style-src
|
||||
'self' 'unsafe-inline'` (external catalog.css + inline `<style>` OK), `connect-src
|
||||
'self'` (only `GET /meta.json`). Verify before shipping: `node --check app.js`,
|
||||
`grep -E 'onerror=|onclick=|onload=' *.js *.html` = 0.
|
||||
ONE page, three files: the editorial story (hero · beliefs · makers band) above a
|
||||
searchable 1,030-app launcher. The three presentation files are the ONLY moving
|
||||
parts; they never touch the contract above (meta.json / blueprints are read-only
|
||||
to them). `static` serves a strict CSP — verified live:
|
||||
`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'`.
|
||||
So: external `app.js` (NO inline `<script>` / handlers), logos same-origin +
|
||||
favicon/brand-mark inline data:/SVG (NEVER an external favicon), external
|
||||
`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:
|
||||
- **DOM-built, never innerHTML.** Cards come from one factory `card(t, opts)`
|
||||
(`opts.spotlight`/`opts.note` = the "Start here" dress). Per-item try/catch —
|
||||
one bad row is skipped, never fatal. Logos degrade to a monogram tile on
|
||||
`error` (proven: page stays whole even if the server vanishes).
|
||||
- **DOM-built, never innerHTML.** Cards come from one factory `card(t)` (logo +
|
||||
name + category badge + Deploy + Source). Per-item try/catch — one bad row is
|
||||
skipped, never fatal. Logos degrade to a monogram tile on `error` (proven: page
|
||||
stays whole even if a logo 404s or the server vanishes).
|
||||
- **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.
|
||||
- **Curated exploration, not a wall of 1030.** `FEATURED` = ~12 hand-picked ids
|
||||
+ one-liners. `DOMAINS` = 8 browse-domains, each a curated **set of real tags**
|
||||
(build-provenance tags caprover/dokploy/coolify/casaos/runtipi are NEVER in a
|
||||
set); filtering is one intersection test. Domain cards + the clear-chip both
|
||||
call `setDomain()` — one path. Search is the power path.
|
||||
- **The 1030-grid is deferred** behind an IntersectionObserver (renders on scroll
|
||||
or first search/filter), paginated 60 at a time, `content-visibility:auto` +
|
||||
lazy logos. Keep it cheap.
|
||||
- **Economics are first-class:** Deploy → `platform.hanzo.ai/templates?deploy=<id>`;
|
||||
maintainer "Earn 20% →" → `console.hanzo.ai/authors?claim=<owner/repo>` parsed
|
||||
from `links.github`. Copy is honest — real count (1,030), real 20%, Techstars '17.
|
||||
- Motion: hero rises on load (always ends visible); below-fold reveal is opt-in
|
||||
only under `@supports (animation-timeline: view())` so unsupported browsers and
|
||||
`prefers-reduced-motion` always render fully visible.
|
||||
instantly and survives a `/meta.json` failure; only the launcher needs data.
|
||||
- **Search + category chips, not a wall of 1030.** The chip row is **data-driven**:
|
||||
a `PREFERRED` order (ai, llm, database, …) shown first when present, then the
|
||||
most-common remaining tags fill up to 16; build-provenance tags
|
||||
(caprover/dokploy/coolify/casaos/runtipi/docker/free/open-source) are hidden.
|
||||
A chip filters by one exact-tag test; search (name+desc+id+tags) is the power
|
||||
path. State is shareable in the URL hash (`#q=…&tag=…`); `/` focuses search,
|
||||
`Esc` clears. Grid paginated 48 at a time, `content-visibility:auto` + lazy logos.
|
||||
- **Theme-aware.** Light default; dark via `prefers-color-scheme` and an explicit
|
||||
`[data-theme]` toggle persisted to `localStorage['hz-theme']`. `app.js` is loaded
|
||||
**blocking in `<head>`** so the theme bootstrap runs before first paint (no flash);
|
||||
the catalog work waits for `DOMContentLoaded`. ONE JS file (theme + launcher).
|
||||
- **Economics are first-class, but the public % is gone:** Deploy →
|
||||
`platform.hanzo.ai/templates?deploy=<id>` (proven live 200). The makers band
|
||||
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.
|
||||
|
||||
@@ -1,152 +1,124 @@
|
||||
// oss.hanzo.ai — the open-source explorer. One file, no framework, no inline JS
|
||||
// (CSP: script-src 'self'). Everything is built with the DOM API — never
|
||||
// innerHTML — so a hostile description can't inject, a missing logo can't break
|
||||
// the grid, and nothing runs afoul of the strict Content-Security-Policy.
|
||||
// Resilient by construction: the story (hero, beliefs, makers) is static HTML
|
||||
// that stands alone; only the catalog below needs data, and one bad row is
|
||||
// skipped, never fatal.
|
||||
// oss.hanzo.ai — one file, no framework, no inline JS (CSP: script-src 'self').
|
||||
// Loaded blocking in <head> so the theme is applied before first paint (no
|
||||
// flash); the catalog work waits for DOMContentLoaded. Everything is built with
|
||||
// the DOM API — never innerHTML — so a hostile description can't inject, a
|
||||
// missing logo can't break the grid, and one bad row is skipped, never fatal.
|
||||
// Reads the SAME /meta.json + /blueprints/<id>/ the PaaS loader consumes.
|
||||
'use strict';
|
||||
|
||||
var DEPLOY = 'https://platform.hanzo.ai/templates?deploy=';
|
||||
var EARN = 'https://console.hanzo.ai/authors';
|
||||
var PAGE = 60;
|
||||
/* ---------------------------------------------------------------- theme ---- */
|
||||
// Applied immediately (this script is blocking in <head>): with an explicit
|
||||
// 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
|
||||
// tags (build-provenance tags — caprover/dokploy/coolify/casaos/runtipi — are
|
||||
// never here). Filtering is one intersection test, parameterised by the set.
|
||||
var DOMAINS = [
|
||||
{ key: 'self-hosted', label: 'Self-hosted',
|
||||
blurb: 'Run it on your own hardware. Your data never leaves the building.',
|
||||
tags: ['self-hosted', 'selfhosted', 'homelab', 'privacy', 'local-first', 'home'] },
|
||||
{ key: 'ai', label: 'AI',
|
||||
blurb: 'Local LLMs, vector search, agents — the whole AI stack, yours to run.',
|
||||
tags: ['ai', 'llm', 'machine-learning', 'chatbot', 'openai', 'anthropic', 'rag', 'agent', 'agents', 'ollama', 'langchain', 'llmops', 'nlp', 'inference', 'generative-ai', 'artificial-intelligence'] },
|
||||
{ key: 'database', label: 'Databases',
|
||||
blurb: 'Where your data lives. Postgres, Redis, and everything that remembers.',
|
||||
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.',
|
||||
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; });
|
||||
function effective() {
|
||||
var set = document.documentElement.getAttribute('data-theme');
|
||||
if (set) return set;
|
||||
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
function wireToggle() {
|
||||
var btn = document.getElementById('theme-toggle');
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', function () {
|
||||
var next = effective() === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
try { localStorage.setItem(KEY, next); } catch (e) { /* ignore */ }
|
||||
});
|
||||
}
|
||||
onReady(wireToggle);
|
||||
})();
|
||||
|
||||
// Start here — hand-picked, iconic, with a plain-English line for what it IS.
|
||||
// Rendered through the same card factory as the grid (one renderer), just in
|
||||
// spotlight dress. A pick missing from the catalog is silently skipped.
|
||||
var FEATURED = [
|
||||
['supabase', 'The open-source Firebase. Postgres, auth, and APIs in one.'],
|
||||
['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.']
|
||||
];
|
||||
/* --------------------------------------------------------------- catalog --- */
|
||||
var DEPLOY = 'https://platform.hanzo.ai/templates?deploy='; // PROVEN live (200)
|
||||
var CATALOG = '/meta.json';
|
||||
var LOGO_BASE = '/blueprints/';
|
||||
var PAGE = 48;
|
||||
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
var el = function (tag, cls, text) {
|
||||
// Build-provenance tags are catalog plumbing, not user-facing categories.
|
||||
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);
|
||||
if (cls) n.className = cls;
|
||||
if (text != null) n.textContent = text;
|
||||
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),
|
||||
// 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;
|
||||
// The most specific, non-generic tag — the card's category badge.
|
||||
function badgeTag(t) {
|
||||
var tags = t.tags || [];
|
||||
for (var i = 0; i < tags.length; i++) if (!GENERIC[tags[i]] && !HIDE[tags[i]]) return tags[i];
|
||||
for (var j = 0; j < tags.length; j++) if (!HIDE[tags[j]]) return tags[j];
|
||||
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');
|
||||
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;
|
||||
img.loading = 'lazy'; img.alt = '';
|
||||
img.src = LOGO_BASE + encodeURIComponent(t.id) + '/' + encodeURIComponent(t.logo);
|
||||
img.addEventListener('error', function () { if (img.parentNode) img.parentNode.replaceChild(monoNode(t.name || t.id), img); });
|
||||
c.appendChild(img);
|
||||
} else {
|
||||
c.appendChild(monoNode(t.name || t.id));
|
||||
}
|
||||
|
||||
// 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');
|
||||
body.appendChild(el('p', 'name', t.name || t.id));
|
||||
body.appendChild(el('p', 'desc', opts.note || t.description || ''));
|
||||
var nameRow = el('div', 'name-row');
|
||||
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 deploy = el('a', 'deploy', 'Deploy');
|
||||
deploy.href = DEPLOY + encodeURIComponent(t.id);
|
||||
deploy.setAttribute('aria-label', 'Deploy ' + (t.name || t.id) + ' on Hanzo');
|
||||
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);
|
||||
var gh = githubUrl(t);
|
||||
if (gh) {
|
||||
var link = el('a', 'link', 'Source');
|
||||
link.href = gh; link.target = '_blank'; link.rel = 'noopener noreferrer';
|
||||
row.appendChild(link);
|
||||
}
|
||||
body.appendChild(row);
|
||||
c.appendChild(body);
|
||||
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) {
|
||||
if (reset) { grid.textContent = ''; shown = 0; }
|
||||
var frag = document.createDocumentFragment();
|
||||
@@ -158,125 +130,116 @@ function render(reset) {
|
||||
shown = end;
|
||||
moreBtn.hidden = shown >= view.length;
|
||||
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() {
|
||||
if (io) { io.disconnect(); io = null; }
|
||||
armed = true;
|
||||
var term = (q.value || '').trim().toLowerCase();
|
||||
view = ALL.filter(function (t) {
|
||||
if (active && !inDomain(t)) return false;
|
||||
var term = q.value.trim().toLowerCase();
|
||||
view = all.filter(function (t) {
|
||||
if (active && !(t.tags || []).some(function (x) { return x === active; })) return false;
|
||||
if (!term) return true;
|
||||
return (t.name || '').toLowerCase().indexOf(term) >= 0 ||
|
||||
(t.description || '').toLowerCase().indexOf(term) >= 0 ||
|
||||
(t.id || '').toLowerCase().indexOf(term) >= 0 ||
|
||||
(t.tags || []).join(' ').toLowerCase().indexOf(term) >= 0;
|
||||
});
|
||||
counts();
|
||||
render(true);
|
||||
syncHash();
|
||||
}
|
||||
|
||||
function chip() {
|
||||
if (!chipEl) return;
|
||||
chipEl.textContent = '';
|
||||
var d = DOMAP[active]; if (!d) return;
|
||||
var b = el('button', 'chip', d.label + ' ✕');
|
||||
b.setAttribute('aria-label', 'Clear ' + d.label + ' filter');
|
||||
b.addEventListener('click', function () { setDomain(''); });
|
||||
chipEl.appendChild(b);
|
||||
// ---- category chips (data-driven: recognizable first, then most common) ----
|
||||
function chipList() {
|
||||
var freq = {};
|
||||
all.forEach(function (t) { (t.tags || []).forEach(function (tag) { if (!HIDE[tag]) freq[tag] = (freq[tag] || 0) + 1; }); });
|
||||
var chosen = [], seen = {};
|
||||
PREFERRED.forEach(function (tag) { if (freq[tag] && !seen[tag]) { chosen.push(tag); seen[tag] = 1; } });
|
||||
Object.keys(freq)
|
||||
.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();
|
||||
if (catalogEl) catalogEl.scrollIntoView({ block: 'start' });
|
||||
}
|
||||
|
||||
function renderFeatured() {
|
||||
if (!featuredEl) return;
|
||||
var frag = document.createDocumentFragment();
|
||||
FEATURED.forEach(function (pair) {
|
||||
try {
|
||||
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 */ }
|
||||
function buildTags() {
|
||||
chipList().forEach(function (tag) {
|
||||
var b = el('button', 'tag', label(tag));
|
||||
b.type = 'button'; b.dataset.tag = tag; b.setAttribute('aria-pressed', 'false');
|
||||
b.addEventListener('click', function () { setActive(tag, b); });
|
||||
tagsEl.appendChild(b);
|
||||
});
|
||||
featuredEl.appendChild(frag);
|
||||
}
|
||||
|
||||
function renderDomains() {
|
||||
if (!domainsEl) return;
|
||||
var frag = document.createDocumentFragment();
|
||||
DOMAINS.forEach(function (d) {
|
||||
try {
|
||||
var n = ALL.filter(function (t) {
|
||||
var tags = t.tags || [];
|
||||
for (var i = 0; i < tags.length; i++) if (d.set[tags[i]]) return true;
|
||||
return false;
|
||||
}).length;
|
||||
if (!n) return;
|
||||
var b = el('button', 'domain');
|
||||
b.setAttribute('data-key', d.key);
|
||||
b.appendChild(el('span', 'domain-count', n.toLocaleString()));
|
||||
b.appendChild(el('span', 'domain-label', d.label));
|
||||
b.appendChild(el('span', 'domain-blurb', d.blurb));
|
||||
b.addEventListener('click', function () { setDomain(d.key); });
|
||||
frag.appendChild(b);
|
||||
} catch (e) { /* skip one domain, never break the row */ }
|
||||
// ---- shareable/deep-linkable state in the URL hash ----
|
||||
function syncHash() {
|
||||
var p = [];
|
||||
if (q.value.trim()) p.push('q=' + encodeURIComponent(q.value.trim()));
|
||||
if (active) p.push('tag=' + encodeURIComponent(active));
|
||||
var url = p.length ? '#' + p.join('&') : location.pathname + location.search;
|
||||
try { history.replaceState(null, '', url); } catch (e) { /* ignore */ }
|
||||
}
|
||||
function readHash() {
|
||||
var h = location.hash.replace(/^#/, '');
|
||||
if (!h) return;
|
||||
var params = {};
|
||||
h.split('&').forEach(function (kv) { var i = kv.indexOf('='); if (i > 0) params[decodeURIComponent(kv.slice(0, i))] = decodeURIComponent(kv.slice(i + 1)); });
|
||||
if (params.q) q.value = params.q;
|
||||
if (params.tag) {
|
||||
active = params.tag;
|
||||
Array.prototype.forEach.call(tagsEl.children, function (c) {
|
||||
if (c.dataset.tag === active) { c.classList.add('on'); c.setAttribute('aria-pressed', 'true'); }
|
||||
});
|
||||
domainsEl.appendChild(frag);
|
||||
}
|
||||
}
|
||||
|
||||
function fail(msg) {
|
||||
if (emptyEl) { emptyEl.hidden = false; emptyEl.textContent = msg; }
|
||||
if (resultEl) resultEl.textContent = '';
|
||||
}
|
||||
function fail(msg) { if (emptyEl) { emptyEl.hidden = false; emptyEl.textContent = msg; } if (resultEl) resultEl.textContent = ''; }
|
||||
|
||||
function start() {
|
||||
grid = $('grid'); q = $('q'); moreBtn = $('more'); emptyEl = $('empty');
|
||||
countEl = $('count'); featuredEl = $('featured'); domainsEl = $('domains');
|
||||
chipEl = $('active-filter'); resultEl = $('result-count');
|
||||
catalogEl = document.querySelector('.catalog');
|
||||
if (!grid) return;
|
||||
grid = document.getElementById('grid'); q = document.getElementById('q');
|
||||
tagsEl = document.getElementById('tags'); moreBtn = document.getElementById('more');
|
||||
emptyEl = document.getElementById('empty'); resultEl = document.getElementById('result-count');
|
||||
if (!grid || !q) 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.');
|
||||
ALL.forEach(function (t) { BYID[t.id] = t; });
|
||||
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 {
|
||||
document.addEventListener('keydown', function (e) {
|
||||
var elx = document.activeElement, typing = elx && (elx.tagName === 'INPUT' || elx.tagName === 'TEXTAREA');
|
||||
if (e.key === '/' && !typing) { e.preventDefault(); q.focus(); q.select(); }
|
||||
else if (e.key === 'Escape') {
|
||||
if (q.value || active) {
|
||||
q.value = ''; active = '';
|
||||
Array.prototype.forEach.call(tagsEl.children, function (c) { c.classList.remove('on'); c.setAttribute('aria-pressed', 'false'); });
|
||||
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') {
|
||||
document.addEventListener('DOMContentLoaded', start);
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
onReady(start);
|
||||
|
||||
+196
-158
@@ -1,234 +1,272 @@
|
||||
/* oss.hanzo.ai — the open-source explorer. The apple of open source:
|
||||
monochrome, Geist, true black, one accent (the white Deploy button).
|
||||
Restraint, rhythm, motion with taste. Every rule earns its place. */
|
||||
/* oss.hanzo.ai — the open-source cloud storefront.
|
||||
One page: the editorial story (hero · beliefs · makers) above a searchable
|
||||
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 {
|
||||
--bg: #000; --panel: #0a0a0b; --panel-hi: #101013;
|
||||
--line: #191a1d; --line-hi: #2a2a30;
|
||||
--fg: #fafafa; --muted: #8d8d95; --faint: #5a5a61;
|
||||
--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);
|
||||
--radius: 14px;
|
||||
--top-h: 3.5rem;
|
||||
--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; }
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
@media (prefers-reduced-motion: no-preference) { html { scroll-behavior: smooth; } }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body {
|
||||
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;
|
||||
background: var(--bg); color: var(--fg); overflow-x: hidden;
|
||||
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;
|
||||
}
|
||||
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; }
|
||||
::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 {
|
||||
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);
|
||||
}
|
||||
|
||||
/* ---- top bar --------------------------------------------------------- */
|
||||
/* ---------------------------------------------------------- header / nav ---- */
|
||||
.top {
|
||||
position: sticky; top: 0; z-index: 30; height: var(--top-h);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 1.75rem;
|
||||
background: rgba(0,0,0,.62); backdrop-filter: saturate(180%) blur(16px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 1rem;
|
||||
padding: 0 1.5rem; border-bottom: 1px solid var(--line);
|
||||
background: var(--bar-bg);
|
||||
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; }
|
||||
.mark {
|
||||
width: 1.15rem; height: 1.15rem; border-radius: 5px; flex: 0 0 auto;
|
||||
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;
|
||||
.brand { display: inline-flex; align-items: center; gap: 0.55rem; font-weight: 600; letter-spacing: -0.02em; font-size: 1.02rem; }
|
||||
.brand .mark { width: 1.25rem; height: 1.25rem; display: block; border-radius: 5px; color: var(--fg); }
|
||||
.brand .sub { color: var(--muted); font-weight: 500; }
|
||||
.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 { max-width: 58rem; margin: 0 auto; padding: 5.5rem 1.5rem 3.5rem; text-align: center; }
|
||||
/* --------------------------------------------------------------- hero ------- */
|
||||
.hero { max-width: 58rem; margin: 0 auto; padding: 5rem 1.5rem 3.5rem; text-align: center; }
|
||||
.hero h1 {
|
||||
margin: 0 0 1.25rem; font-size: clamp(2.5rem, 7vw, 4.75rem); line-height: 1.02;
|
||||
letter-spacing: -0.045em;
|
||||
margin: 0 0 1.25rem; font-size: clamp(2.4rem, 6.4vw, 4.5rem); line-height: 1.03; letter-spacing: -0.045em;
|
||||
}
|
||||
.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; }
|
||||
|
||||
.cta { display: flex; gap: .75rem; justify-content: center; flex-wrap: wrap; }
|
||||
.cta { display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap; }
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; padding: .72rem 1.4rem;
|
||||
font-size: .92rem; font-weight: 600; border-radius: 10px;
|
||||
border: 1px solid transparent; cursor: pointer;
|
||||
transition: background .16s var(--ease), border-color .16s var(--ease), color .16s var(--ease), transform .16s var(--ease);
|
||||
display: inline-flex; align-items: center; padding: 0.72rem 1.4rem;
|
||||
font-size: 0.92rem; font-weight: 600; border-radius: 10px; 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);
|
||||
}
|
||||
.btn.primary { color: #000; background: var(--fg); }
|
||||
.btn.primary:hover { background: #fff; transform: translateY(-1px); }
|
||||
.btn.ghost { color: var(--fg); border-color: var(--line-hi); background: transparent; }
|
||||
.btn.ghost:hover { border-color: #555; background: var(--panel); }
|
||||
.btn.primary { color: var(--btn-fg); background: var(--btn-bg); }
|
||||
.btn.primary:hover { background: var(--btn-bg-hover); transform: translateY(-1px); }
|
||||
.btn.ghost { color: var(--fg); border-color: var(--line-2); background: transparent; }
|
||||
.btn.ghost:hover { border-color: var(--muted); background: var(--panel-2); }
|
||||
|
||||
/* ---- philosophy band ------------------------------------------------- */
|
||||
/* ---- philosophy band ------------------------------------------------------ */
|
||||
.beliefs {
|
||||
max-width: 78rem; margin: 0 auto; padding: 3rem 1.5rem 4rem;
|
||||
border-top: 1px solid var(--line); border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.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 p { margin: 0; color: var(--muted); font-size: .95rem; line-height: 1.55; }
|
||||
.signature { margin: 2.75rem 0 0; text-align: center; font-size: .84rem; color: var(--faint); }
|
||||
.belief h3 { font-size: 1.16rem; letter-spacing: -0.02em; margin-bottom: 0.5rem; }
|
||||
.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: 0.84rem; color: var(--faint); }
|
||||
|
||||
/* ---- makers band ----------------------------------------------------- */
|
||||
.makers {
|
||||
max-width: 46rem; margin: 0 auto; padding: 5rem 1.5rem; text-align: center;
|
||||
scroll-margin-top: var(--top-h);
|
||||
}
|
||||
/* ---- makers band ---------------------------------------------------------- */
|
||||
.makers { 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-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; }
|
||||
.steps {
|
||||
list-style: none; margin: 0 0 2.5rem; padding: 0;
|
||||
display: flex; gap: .75rem; justify-content: center; flex-wrap: wrap;
|
||||
}
|
||||
.steps { list-style: none; margin: 0 0 2.5rem; padding: 0; display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap; }
|
||||
.steps li {
|
||||
display: flex; align-items: center; gap: .6rem;
|
||||
padding: .7rem 1.15rem; font-size: .9rem; color: var(--fg);
|
||||
display: flex; align-items: center; gap: 0.6rem; padding: 0.7rem 1.15rem; font-size: 0.9rem; color: var(--fg);
|
||||
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 ----------------------------------- */
|
||||
main { max-width: 84rem; margin: 0 auto; padding: 0 1.5rem 5rem; }
|
||||
.section-head { margin: 3.5rem 0 1.5rem; }
|
||||
.section-head h2 { font-size: clamp(1.5rem, 3.5vw, 2.1rem); letter-spacing: -0.035em; }
|
||||
.section-head p { margin: .5rem 0 0; color: var(--muted); font-size: 1rem; }
|
||||
/* ---- explore: section head ------------------------------------------------ */
|
||||
main { max-width: 84rem; margin: 0 auto; padding: 0 1.5rem 4rem; scroll-margin-top: var(--top-h); }
|
||||
.section-head { max-width: 58rem; margin: 3.5rem auto 1.75rem; text-align: center; }
|
||||
.section-head h2 { font-size: clamp(1.6rem, 3.6vw, 2.2rem); letter-spacing: -0.035em; }
|
||||
.section-head p { margin: 0.5rem 0 0; color: var(--muted); font-size: 1rem; }
|
||||
|
||||
/* ---- curated: "Start here" spotlight cards --------------------------- */
|
||||
.featured { display: grid; gap: .85rem; grid-template-columns: repeat(auto-fill, minmax(21.5rem, 1fr)); }
|
||||
|
||||
/* ---- browse: domain cards -------------------------------------------- */
|
||||
.domains { display: grid; gap: .85rem; grid-template-columns: repeat(auto-fill, minmax(17.5rem, 1fr)); }
|
||||
.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 + category chips ---------------------------------------------- */
|
||||
.search-wrap { position: relative; max-width: 40rem; margin: 0 auto; }
|
||||
.search-wrap .glass {
|
||||
position: absolute; left: 1rem; top: 50%; transform: translateY(-50%);
|
||||
width: 18px; height: 18px; color: var(--faint); pointer-events: none;
|
||||
}
|
||||
.search {
|
||||
flex: 1 1 22rem; min-width: 0;
|
||||
padding: .85rem 1.15rem; font-size: 1rem; color: var(--fg);
|
||||
background: var(--panel); border: 1px solid var(--line-hi); border-radius: 11px;
|
||||
outline: none; transition: border-color .15s var(--ease), background .15s var(--ease);
|
||||
width: 100%; display: block; background: var(--panel); border: 1px solid var(--line-2); border-radius: 12px;
|
||||
color: var(--fg); font-size: 1rem; padding: 0.9rem 2.9rem 0.9rem 2.85rem; outline: none;
|
||||
box-shadow: var(--shadow); transition: border-color 0.15s var(--ease), box-shadow 0.15s var(--ease);
|
||||
}
|
||||
.search:focus { border-color: var(--fg); box-shadow: 0 0 0 4px var(--ring); }
|
||||
.search::placeholder { color: var(--faint); }
|
||||
.search:focus { border-color: #555; background: var(--panel-hi); }
|
||||
.toolbar-meta { display: flex; align-items: center; gap: .75rem; margin-left: auto; }
|
||||
.chip {
|
||||
padding: .4rem .8rem; font-size: .82rem; font-weight: 500; color: #000;
|
||||
background: var(--fg); border: 1px solid var(--fg); border-radius: 999px; cursor: pointer;
|
||||
transition: opacity .15s var(--ease);
|
||||
.kbd {
|
||||
position: absolute; right: 0.75rem; top: 50%; transform: translateY(-50%);
|
||||
font-size: 0.72rem; color: var(--faint); border: 1px solid var(--line);
|
||||
border-radius: 6px; padding: 0.1rem 0.4rem; background: var(--panel-2);
|
||||
}
|
||||
.chip:hover { opacity: .82; }
|
||||
.result-count { font-size: .85rem; color: var(--muted); white-space: nowrap; }
|
||||
.result-count { color: var(--faint); font-size: 0.82rem; margin: 0.9rem 0 0; min-height: 1.1em; text-align: center; }
|
||||
.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 -------------------------------------------------------- */
|
||||
.grid { display: grid; gap: .85rem; grid-template-columns: repeat(auto-fill, minmax(19.5rem, 1fr)); }
|
||||
/* ---- the grid ------------------------------------------------------------- */
|
||||
.grid { display: grid; gap: 0.8rem; grid-template-columns: repeat(auto-fill, minmax(18.5rem, 1fr)); margin-top: 2rem; }
|
||||
.card {
|
||||
content-visibility: auto; contain-intrinsic-size: 0 116px;
|
||||
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);
|
||||
content-visibility: auto; contain-intrinsic-size: 0 118px;
|
||||
display: flex; gap: 0.9rem; align-items: flex-start; padding: 1rem;
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow);
|
||||
transition: border-color 0.16s var(--ease), box-shadow 0.16s var(--ease), transform 0.16s var(--ease);
|
||||
}
|
||||
.card:hover { border-color: var(--line-hi); background: var(--panel-hi); transform: translateY(-2px); }
|
||||
.card.spot { content-visibility: visible; padding: 1.35rem; }
|
||||
.card:hover { border-color: var(--line-2); box-shadow: var(--shadow-hover); transform: translateY(-1px); }
|
||||
.logo {
|
||||
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 {
|
||||
display: grid; place-items: center; padding: 0;
|
||||
background: var(--line-hi); color: var(--fg); font-weight: 600; font-size: 1.15rem;
|
||||
.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; }
|
||||
.body { min-width: 0; flex: 1; }
|
||||
.name-row { display: flex; align-items: baseline; gap: 0.5rem; }
|
||||
.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 {
|
||||
margin: .25rem 0 .75rem; font-size: .84rem; color: var(--muted); line-height: 1.48;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||
color: var(--muted); font-size: 0.83rem; margin: 0.28rem 0 0.7rem; line-height: 1.5;
|
||||
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: .6rem; flex-wrap: wrap; }
|
||||
.row { display: flex; align-items: center; gap: 0.55rem; flex-wrap: wrap; }
|
||||
.deploy {
|
||||
padding: .36rem .85rem; font-size: .8rem; font-weight: 600; color: #000;
|
||||
background: var(--fg); border-radius: 8px; transition: background .15s var(--ease), transform .15s var(--ease);
|
||||
display: inline-flex; align-items: center; gap: 0.35rem; font-size: 0.82rem; font-weight: 550;
|
||||
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); }
|
||||
.link { font-size: .8rem; color: var(--muted); transition: color .15s var(--ease); }
|
||||
.deploy:hover { background: var(--btn-bg-hover); color: var(--btn-fg); transform: translateY(-1px); }
|
||||
.link { font-size: 0.8rem; color: var(--muted); padding: 0.36rem 0.4rem; transition: color 0.15s var(--ease); }
|
||||
.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 {
|
||||
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);
|
||||
display: block; margin: 2.5rem auto 0; background: var(--panel); color: var(--fg);
|
||||
border: 1px solid var(--line-2); border-radius: 10px; padding: 0.75rem 1.75rem; cursor: pointer; font-size: 0.9rem;
|
||||
transition: border-color 0.15s var(--ease), background 0.15s var(--ease);
|
||||
}
|
||||
.more:hover { border-color: #555; }
|
||||
.empty { padding: 4rem 0; text-align: center; color: var(--muted); }
|
||||
.more:hover { border-color: var(--fg); background: var(--panel-2); }
|
||||
.empty { text-align: center; color: var(--muted); padding: 4rem 1rem; font-size: 0.95rem; }
|
||||
|
||||
/* ---- footer ---------------------------------------------------------- */
|
||||
/* ---- footer --------------------------------------------------------------- */
|
||||
.foot {
|
||||
max-width: 84rem; margin: 0 auto; padding: 2.5rem 1.5rem;
|
||||
display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap;
|
||||
border-top: 1px solid var(--line); color: var(--faint); font-size: .8rem;
|
||||
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; align-items: center;
|
||||
color: var(--faint); font-size: 0.8rem;
|
||||
}
|
||||
.foot a { color: var(--muted); }
|
||||
.foot a:hover { color: var(--fg); }
|
||||
.foot a { color: var(--muted); border-bottom: 1px solid transparent; }
|
||||
.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) {
|
||||
.top { padding: 0 1.15rem; }
|
||||
.belt { grid-template-columns: 1fr; gap: 1.5rem; }
|
||||
.hero { padding: 3.5rem 1.25rem 2.5rem; }
|
||||
.toolbar-meta { margin-left: 0; width: 100%; justify-content: space-between; }
|
||||
.search { flex-basis: 100%; }
|
||||
.makers { padding: 3.5rem 1.25rem; }
|
||||
.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 ----------------------------------------------- */
|
||||
/* Hero rises once on load — always ends visible, so nothing can hide it. */
|
||||
/* ---- motion: hero rises once on load (always ends visible) ----------------- */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.hero > * { animation: rise .6s var(--ease) both; }
|
||||
.hero .eyebrow { animation-delay: .02s; }
|
||||
.hero h1 { animation-delay: .08s; }
|
||||
.hero .lead { animation-delay: .16s; }
|
||||
.hero .cta { animation-delay: .24s; }
|
||||
.hero > * { animation: rise 0.6s var(--ease) both; }
|
||||
.hero .eyebrow { animation-delay: 0.02s; }
|
||||
.hero h1 { animation-delay: 0.08s; }
|
||||
.hero .lead { animation-delay: 0.16s; }
|
||||
.hero .cta { animation-delay: 0.24s; }
|
||||
}
|
||||
@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; } }
|
||||
|
||||
+36
-35
@@ -4,19 +4,28 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<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="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>
|
||||
<body>
|
||||
<!-- header -->
|
||||
<header class="top">
|
||||
<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>
|
||||
<nav class="topnav">
|
||||
<a href="#explore">Explore</a>
|
||||
<a href="#makers">Earn</a>
|
||||
<a href="https://docs.hanzo.ai">Docs</a>
|
||||
<nav class="topnav" aria-label="Primary">
|
||||
<a class="hide-sm" href="#explore">Explore</a>
|
||||
<a class="hide-sm" href="#makers">Earn</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>
|
||||
</header>
|
||||
|
||||
@@ -48,7 +57,7 @@
|
||||
</article>
|
||||
<article class="belief">
|
||||
<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>
|
||||
</div>
|
||||
<p class="signature">Open by default. Building in the open since Techstars '17.</p>
|
||||
@@ -59,8 +68,8 @@
|
||||
<p class="eyebrow">For makers</p>
|
||||
<h2>Open source that pays.</h2>
|
||||
<p class="makers-lead">
|
||||
Every app here is somebody's work. When your project runs on Hanzo you keep
|
||||
<strong>20% of the compute revenue</strong> — metered per second, paid out automatically.
|
||||
Every app here is somebody's work. When your project runs on Hanzo you keep a
|
||||
<strong>share of the compute it earns</strong> — metered per second, paid out automatically.
|
||||
No invoices. No gatekeepers.
|
||||
</p>
|
||||
<ol class="steps">
|
||||
@@ -71,43 +80,35 @@
|
||||
<a class="btn primary" href="https://console.hanzo.ai/authors">Claim your project →</a>
|
||||
</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">
|
||||
<section class="curated" aria-label="Start here">
|
||||
<div class="section-head">
|
||||
<h2>Start here</h2>
|
||||
<p>The ones everyone deploys first.</p>
|
||||
<h2>Explore the catalog</h2>
|
||||
<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 id="featured" class="featured"></div>
|
||||
</section>
|
||||
|
||||
<section class="browse" aria-label="Browse by domain">
|
||||
<div class="section-head">
|
||||
<h2>Browse by what you want to do</h2>
|
||||
<p>Eight domains. Pick a lane — the catalog follows.</p>
|
||||
<div class="search-wrap">
|
||||
<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>
|
||||
<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" />
|
||||
<span class="kbd" aria-hidden="true">/</span>
|
||||
</div>
|
||||
<div id="domains" class="domains"></div>
|
||||
</section>
|
||||
<p class="result-count" id="result-count" role="status" aria-live="polite"></p>
|
||||
<div class="tags" id="tags" role="group" aria-label="Filter by category"></div>
|
||||
|
||||
<section class="catalog" aria-label="All apps">
|
||||
<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>
|
||||
<div id="grid" class="grid" aria-label="Open-source apps" aria-live="polite"></div>
|
||||
<p id="empty" class="empty" hidden>No apps match — try another term.</p>
|
||||
<button id="more" class="more" hidden>Load more</button>
|
||||
</section>
|
||||
<button id="more" class="more" type="button" hidden>Load more</button>
|
||||
</main>
|
||||
|
||||
<!-- footer -->
|
||||
<footer class="foot">
|
||||
<span>Sourced from the open-source Dokploy, Coolify, CapRover, CasaOS & Runtipi catalogs — each app keeps its upstream license.</span>
|
||||
<span class="links">
|
||||
<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>
|
||||
|
||||
<script src="/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user