mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-28 07:32:52 +00:00
The share card offers Hanzo, the products and the betas the member asked for, the rest under More by group. A topic picks the posts Shuffle offers, the link (/beta/for/<topic>, tagged utm_content) and the card it unfurls to: that page carries the topic's title, line and card, a canonical naming itself and the one noindex (lib/publish SHARES), and sends a person on to /beta with the query kept, so ref and the campaign land as before. lib/hanzo/topics is the list, its words the site's and BETAS's own. generate-og draws a card per topic from it, palette-quantized with sharp: 49 cards, 2.9 MB. The cards gate reads every card's rendered words. Claude-Session: https://claude.ai/code/session_017vrVus6PPBgP5YRzKV1iqW
783 lines
28 KiB
JavaScript
783 lines
28 KiB
JavaScript
import { chromium } from '@playwright/test'
|
||
import { createHash } from 'node:crypto'
|
||
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||
import { TOPICS, picture } from '../lib/hanzo/topics.ts'
|
||
|
||
/**
|
||
* The share cards: `node scripts/generate-og.mjs` draws every one,
|
||
* `node scripts/generate-og.mjs pricing api` the named ones alone.
|
||
*
|
||
* Each draw records, in scripts/og.json, the hash of the page a card was drawn
|
||
* from and of the image it made, so e2e/unit/cards.spec.ts can hold every image
|
||
* to the words below.
|
||
*/
|
||
|
||
/** Where a card's image lives: the default card at the root of `public/`, the rest in `public/og/`. */
|
||
export const file = (name) => (name === 'og-image' ? 'public/og-image.png' : `public/og/${name}.png`)
|
||
|
||
/** The draw record. */
|
||
export const MANIFEST = new URL('./og.json', import.meta.url)
|
||
|
||
const sha = (bytes) => createHash('sha256').update(bytes).digest('hex')
|
||
|
||
const HANZO_MARK_SVG = `
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 67 67" width="36" height="36" role="img" aria-label="Hanzo">
|
||
<path fill="#ffffff" d="M22.21 67V44.6369H0V67H22.21Z"/>
|
||
<path fill="#ffffff" d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z"/>
|
||
<path fill="#ffffff" d="M22.21 0H0V22.3184H22.21V0Z"/>
|
||
<path fill="#ffffff" d="M66.7198 0H44.5098V22.3184H66.7198V0Z"/>
|
||
<path fill="#ffffff" d="M66.7198 67V44.6369H44.5098V67H66.7198Z"/>
|
||
</svg>
|
||
`
|
||
|
||
export const CARDS = [
|
||
// The landing's own words (components/home), and what anyone can check: a Free
|
||
// plan, one API at api.hanzo.ai, and the CLI the landing shows.
|
||
{
|
||
name: 'og-image',
|
||
badge: 'HANZO AI',
|
||
title: "Build what's next.",
|
||
lede: 'The AI cloud and app builder. Describe an app, an agent or a company, and Hanzo builds it, ships it and runs it.',
|
||
pills: ['Free to start', 'One API at api.hanzo.ai', 'Chat, Dev and agents'],
|
||
accent: '#3b82f6',
|
||
accentRgb: '59, 130, 246',
|
||
visual: 'terminal',
|
||
},
|
||
{
|
||
name: 'home',
|
||
badge: 'HANZO AI',
|
||
title: "Build what's next.",
|
||
lede: 'The AI cloud and app builder. Describe an app, an agent or a company, and Hanzo builds it, ships it and runs it.',
|
||
pills: ['Free to start', 'One API at api.hanzo.ai', 'Chat, Dev and agents'],
|
||
accent: '#3b82f6',
|
||
accentRgb: '59, 130, 246',
|
||
visual: 'terminal',
|
||
},
|
||
// /beta's own title and description (app/beta/layout.tsx): the card a member's link unfurls to.
|
||
{
|
||
name: 'beta',
|
||
badge: 'BETA',
|
||
title: 'Get there first.',
|
||
lede: 'Early access to Dev, Studio, Kai, Enso and Zen.',
|
||
pills: ['Dev', 'Studio', 'Kai'],
|
||
accent: '#8b5cf6',
|
||
accentRgb: '139, 92, 246',
|
||
visual: 'terminal',
|
||
},
|
||
// The sign-in page's providers (components/auth/panel.tsx) and the CLI's device sign-in.
|
||
{
|
||
name: 'login',
|
||
badge: 'SIGN IN',
|
||
title: 'Welcome back to Hanzo.',
|
||
lede: 'One Hanzo account for Chat, Dev, the console and your API keys.',
|
||
pills: ['Google', 'GitHub', 'Email'],
|
||
accent: '#06b6d4',
|
||
accentRgb: '6, 182, 212',
|
||
visual: 'terminal',
|
||
},
|
||
// The Free plan's own terms (GET /v1/billing/plans, `free`).
|
||
{
|
||
name: 'signup',
|
||
badge: 'GET STARTED',
|
||
title: 'Create your account.',
|
||
lede: 'Start free with Enso Free and Zen Free, with 50 free requests every day. No card needed.',
|
||
pills: ['Free to start', 'No card needed', 'Google, GitHub or email'],
|
||
accent: '#3b82f6',
|
||
accentRgb: '59, 130, 246',
|
||
visual: 'terminal',
|
||
},
|
||
// /pricing's own description, and the Free, Pro and Team rows of GET /v1/billing/plans.
|
||
{
|
||
name: 'pricing',
|
||
badge: 'PRICING',
|
||
title: 'Start free.',
|
||
lede: 'Enso Free and Zen Free cost nothing. Paid plans open every model, include usage and add your own workspace. Team is per member; Enterprise is custom.',
|
||
pills: ['Free: $0', 'Pro: $20 a month', 'Team: $25 a member'],
|
||
accent: '#10b981',
|
||
accentRgb: '16, 185, 129',
|
||
visual: 'pricing',
|
||
},
|
||
// Hanzo's own model ids as GET /v1/models serves them; Zen is Zoo Labs', served here.
|
||
{
|
||
name: 'models',
|
||
badge: 'MODELS',
|
||
title: 'Every model, one key.',
|
||
lede: 'Enso and Kai are Hanzo’s own. Zen and models from other labs answer on the same endpoint with the same key.',
|
||
pills: ['Enso', 'Kai', 'Zen'],
|
||
accent: '#8b5cf6',
|
||
accentRgb: '139, 92, 246',
|
||
visual: 'rows',
|
||
window: 'GET /v1/models',
|
||
mono: true,
|
||
rows: [
|
||
['enso-ultra', '1M-token context · reads images'],
|
||
['enso-flash', '900K-token context · reads images'],
|
||
['kai', 'Answers typed questions at /v1/decisions'],
|
||
['zen6', '1M-token context · free'],
|
||
],
|
||
},
|
||
// /api's own title and description.
|
||
{
|
||
name: 'api',
|
||
badge: 'API',
|
||
title: 'One API for models, agents and tools.',
|
||
lede: 'Every Hanzo capability answers under one base URL, behind one key. If you have used the OpenAI API, you already know most of this.',
|
||
pills: ['api.hanzo.ai/v1', 'One key, one bill', 'Clients from OpenAPI'],
|
||
accent: '#06b6d4',
|
||
accentRgb: '6, 182, 212',
|
||
visual: 'api',
|
||
},
|
||
{
|
||
// The Enterprise plan's own terms (GET /v1/billing/plans, `enterprise`).
|
||
name: 'enterprise',
|
||
badge: 'HANZO ENTERPRISE',
|
||
title: 'Give every team AI.',
|
||
lede: 'Run Hanzo in your cloud, on your servers or fully offline, with dedicated compute and 24/7 engineering support.',
|
||
pills: ['Your cloud or ours', 'Fully offline', 'Single sign-on'],
|
||
accent: '#f59e0b',
|
||
accentRgb: '245, 158, 11',
|
||
visual: 'enterprise',
|
||
},
|
||
// /careers' own words: the team, how it works, and the four hiring steps.
|
||
{
|
||
name: 'careers',
|
||
badge: 'CAREERS',
|
||
title: 'Build an open AI cloud with us.',
|
||
lede: 'Hanzo is a small team building an open AI cloud: the inference engine, the models, the backend services and the agents that use them.',
|
||
pills: ['Remote', 'Mostly open source', 'No whiteboarding'],
|
||
accent: '#10b981',
|
||
accentRgb: '16, 185, 129',
|
||
visual: 'rows',
|
||
window: 'How we hire',
|
||
rows: [
|
||
['1. We read your application', 'Your resume and what you send with it'],
|
||
['2. We get to know you', 'And the work you have done'],
|
||
['3. You show your skills', 'No whiteboarding'],
|
||
['4. You meet the team', 'And we talk about next steps'],
|
||
],
|
||
},
|
||
// /research's own words and four of the specifications it links.
|
||
{
|
||
name: 'research',
|
||
badge: 'RESEARCH',
|
||
title: 'Work we publish.',
|
||
lede: 'The reasoning behind what Hanzo builds is published: papers at papers.hanzo.ai and specifications at hips.hanzo.ai.',
|
||
pills: ['papers.hanzo.ai', 'hips.hanzo.ai', 'Code on GitHub'],
|
||
accent: '#8b5cf6',
|
||
accentRgb: '139, 92, 246',
|
||
visual: 'rows',
|
||
window: 'hips.hanzo.ai',
|
||
mono: true,
|
||
rows: [
|
||
['HIP-0000', 'The architecture framework'],
|
||
['HIP-1211', 'The model API'],
|
||
['HIP-1332', 'Kai, the decision model'],
|
||
['HIP-1145', 'How an experiment is recorded'],
|
||
],
|
||
},
|
||
// Only what /trust states, each a mechanism in the code: one identity service,
|
||
// argon2id, PKCE S256, single-use refresh tokens, a database file per
|
||
// organization on Hanzo Base under a key derived for it, SCIM 2.0, ML-DSA-65
|
||
// among the signature algorithms, and the audit trail.
|
||
{
|
||
name: 'security',
|
||
badge: 'SECURITY',
|
||
title: 'Security you can check.',
|
||
lede: 'One identity service for every sign-in, a database file per organization on Hanzo Base, and an audit trail of who did what.',
|
||
pills: ['argon2id passwords', 'SCIM 2.0', 'Audit trail'],
|
||
accent: '#3b82f6',
|
||
accentRgb: '59, 130, 246',
|
||
visual: 'security',
|
||
},
|
||
// /products: Hanzo OS, its positioning and its layers (lib/layers.ts).
|
||
{
|
||
name: 'products',
|
||
badge: 'PRODUCT',
|
||
title: 'Hanzo OS.',
|
||
lede: 'A human signs a company into existence. After genesis, agents can run it.',
|
||
pills: ['Company', 'Agents', 'Machines'],
|
||
accent: '#06b6d4',
|
||
accentRgb: '6, 182, 212',
|
||
visual: 'rows',
|
||
window: 'Hanzo OS',
|
||
rows: [
|
||
['Company', 'Formation, cap table, documents and filings'],
|
||
['Agents', 'A model, instructions, tools and a budget'],
|
||
['Models', 'Every model behind one endpoint and one key'],
|
||
['Identity', 'Who is acting, and what it may reach'],
|
||
['Record', 'Every act on every layer, in one trail'],
|
||
],
|
||
},
|
||
// /about's own timeline (components/about/HistoryTimeline.tsx).
|
||
{
|
||
name: 'about',
|
||
badge: 'ABOUT',
|
||
title: 'Building since 2016.',
|
||
lede: "Hanzo AI started in 2016 and went through Techstars '17. Today it builds an AI cloud, Hanzo Dev, and the Enso and Kai models.",
|
||
pills: ['Since 2016', "Techstars '17", 'Enso and Kai'],
|
||
accent: '#3b82f6',
|
||
accentRgb: '59, 130, 246',
|
||
visual: 'rows',
|
||
window: 'Timeline',
|
||
rows: [
|
||
['2016', 'Hanzo AI starts'],
|
||
['2017', "Techstars '17"],
|
||
['2025', 'An MCP server, Operative and Hanzo Dev'],
|
||
['2026', 'Enso in July, Kai in September'],
|
||
],
|
||
},
|
||
]
|
||
|
||
/** A beta group's accent, one of the five the cards above use; a product takes the beta card's. */
|
||
const ACCENTS = {
|
||
Business: ['#f59e0b', '245, 158, 11'],
|
||
Data: ['#06b6d4', '6, 182, 212'],
|
||
Agents: ['#8b5cf6', '139, 92, 246'],
|
||
'Build & run': ['#3b82f6', '59, 130, 246'],
|
||
Security: ['#10b981', '16, 185, 129'],
|
||
Creative: ['#8b5cf6', '139, 92, 246'],
|
||
Models: ['#06b6d4', '6, 182, 212'],
|
||
}
|
||
const BETA = ['#8b5cf6', '139, 92, 246']
|
||
|
||
/**
|
||
* A share topic's card (lib/hanzo/topics; app/beta/for/[topic] names it): its
|
||
* name large, its group, its line, and the beta's list with its row lit beside
|
||
* the next three of its kind. Every word is the topic's own or BETAS's, so the
|
||
* card says only what the beta page says. Hanzo's is the beta card above.
|
||
*/
|
||
function topicCard(topic) {
|
||
const kin = TOPICS.filter((t) => t.kind === topic.kind)
|
||
const at = kin.indexOf(topic)
|
||
const rows = [0, 1, 2, 3].map((n) => kin[(at + n) % kin.length]).map((t) => [t.name, t.line])
|
||
const [accent, accentRgb] = topic.group ? ACCENTS[topic.group] : BETA
|
||
// A short name is drawn as the card's subject; a long one at the title's own size.
|
||
const size = topic.name.length <= 11 ? 'font-size: 84px; line-height: 1.04;' : ''
|
||
return {
|
||
name: picture(topic.id),
|
||
badge: topic.group ? `Beta · ${topic.group}` : 'Beta',
|
||
title: topic.name,
|
||
lede: `${topic.line}.`,
|
||
pills: ['In beta', 'Early access at hanzo.ai/beta'],
|
||
accent,
|
||
accentRgb,
|
||
visual: 'rows',
|
||
window: 'hanzo.ai/beta',
|
||
rows,
|
||
at: 0,
|
||
css: `.title { ${size} overflow-wrap: anywhere; } .row.on { margin-inline: -22px; padding-inline: 19px 22px; border-left: 3px solid ${accent}; background: rgba(${accentRgb}, 0.1); } .row.on .row-key { color: ${accent}; }`,
|
||
}
|
||
}
|
||
|
||
CARDS.push(...TOPICS.filter((t) => t.kind !== 'hanzo').map(topicCard))
|
||
|
||
function renderVisual(card) {
|
||
switch (card.visual) {
|
||
case 'terminal':
|
||
// The landing's demo (components/home/Demo.tsx), shortened to fit the card.
|
||
return `
|
||
<div class="mockup-terminal">
|
||
<div class="card-glow" style="--glow-rgb: ${card.accentRgb}"></div>
|
||
<div class="window-shell terminal-shell">
|
||
<div class="window-bar">
|
||
<span class="dot red"></span>
|
||
<span class="dot yellow"></span>
|
||
<span class="dot green"></span>
|
||
<span class="window-title">hanzo cli · zsh</span>
|
||
</div>
|
||
<div class="terminal-content">
|
||
<div class="line cmd"><span class="prompt">$</span> hanzo login</div>
|
||
<div class="line dim">Sign in to hanzo on any device:</div>
|
||
<div class="line out link">https://hanzo.id/device/WDJB-MJHT</div>
|
||
<div class="line dim">Waiting for approval…</div>
|
||
<div class="line success">✓ Signed in to hanzo as hanzo/ada</div>
|
||
<div class="line cmd" style="margin-top: 14px;"><span class="prompt">$</span> hanzo dev "build a shift board"</div>
|
||
<div class="line out">hanzo code dev · ~/shift-board · start</div>
|
||
<div class="line dim"> model routing: on → api.hanzo.ai</div>
|
||
<div class="line dim"> session stream: on → hanzo.bot</div>
|
||
<div class="line prompt-cursor"><span class="prompt">$</span> <span class="cursor"></span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`
|
||
case 'pricing':
|
||
return `
|
||
<div class="mockup-pricing">
|
||
<div class="pricing-card">
|
||
<div class="plan-badge">FREE</div>
|
||
<div class="plan-price">$0</div>
|
||
<div class="plan-features">
|
||
<div>✓ Enso Free and Zen Free</div>
|
||
<div>✓ 50 free requests every day</div>
|
||
<div>✓ Search the web from chat</div>
|
||
</div>
|
||
</div>
|
||
<div class="pricing-card popular" style="--glow-rgb: ${card.accentRgb}">
|
||
<div class="plan-badge">PRO</div>
|
||
<div class="plan-price">$20<span>/month</span></div>
|
||
<div class="plan-features">
|
||
<div>✓ Build apps with Hanzo Dev</div>
|
||
<div>✓ Every model, with Enso Ultra and Kai</div>
|
||
<div>✓ Usage included on every model</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`
|
||
case 'rows':
|
||
return `
|
||
<div class="mockup-rows" style="--glow-rgb: ${card.accentRgb}">
|
||
<div class="card-glow"></div>
|
||
<div class="window-shell">
|
||
<div class="window-bar">
|
||
<span class="dot red"></span>
|
||
<span class="dot yellow"></span>
|
||
<span class="dot green"></span>
|
||
<span class="window-title">${card.window}</span>
|
||
</div>
|
||
<div class="rows">
|
||
${card.rows.map(([key, value], i) => `<div class="row${i === card.at ? ' on' : ''}"><span class="row-key${card.mono ? ' mono' : ''}">${key}</span><span class="row-value">${value}</span></div>`).join('')}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`
|
||
case 'api':
|
||
return `
|
||
<div class="mockup-code">
|
||
<div class="card-glow" style="--glow-rgb: ${card.accentRgb}"></div>
|
||
<div class="window-shell">
|
||
<div class="window-bar">
|
||
<span class="dot red"></span>
|
||
<span class="dot yellow"></span>
|
||
<span class="dot green"></span>
|
||
<span class="window-title">zsh</span>
|
||
</div>
|
||
<div class="code-content">
|
||
<span class="k">$</span> curl https://api.hanzo.ai/v1/chat/completions \\<br/>
|
||
-H <span class="s">"Authorization: Bearer $HANZO_API_KEY"</span> \\<br/>
|
||
-d <span class="s">'{"model": "enso", "stream": true,</span><br/>
|
||
<span class="s"> "messages": [{"role": "user",</span><br/>
|
||
<span class="s"> "content": "Hello"}]}'</span><br/><br/>
|
||
<span class="f">GET</span> /v1/models<br/>
|
||
<span class="f">POST</span> /v1/embeddings<br/>
|
||
<span class="f">GET</span> /v1/agents<br/>
|
||
<span class="f">GET</span> /v1/tools
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`
|
||
case 'security':
|
||
return `
|
||
<div class="mockup-enterprise">
|
||
<div class="card-glow" style="--glow-rgb: ${card.accentRgb}"></div>
|
||
<div class="enterprise-box sec-box">
|
||
<div class="ent-header">
|
||
<div class="ent-shield">🔐</div>
|
||
<div>
|
||
<div class="ent-title">Each control is a mechanism</div>
|
||
<div class="ent-sub">Named on hanzo.ai/trust, readable in the source</div>
|
||
</div>
|
||
</div>
|
||
<div class="ent-badges sec-badges">
|
||
<span class="ent-badge">PKCE S256</span>
|
||
<span class="ent-badge">ARGON2ID</span>
|
||
<span class="ent-badge">HKDF-SHA256</span>
|
||
<span class="ent-badge">ML-DSA-65</span>
|
||
</div>
|
||
<div class="ent-metrics">
|
||
<div class="metric"><span class="m-val">1 file</span><span class="m-lbl">Per org on Base</span></div>
|
||
<div class="metric"><span class="m-val">Single-use</span><span class="m-lbl">Refresh tokens</span></div>
|
||
<div class="metric"><span class="m-val">Who · when</span><span class="m-lbl">Audit trail</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`
|
||
case 'enterprise':
|
||
return `
|
||
<div class="mockup-enterprise">
|
||
<div class="card-glow" style="--glow-rgb: ${card.accentRgb}"></div>
|
||
<div class="enterprise-box">
|
||
<div class="ent-header">
|
||
<div class="ent-shield">🛡️</div>
|
||
<div>
|
||
<div class="ent-title">Run it where you choose</div>
|
||
<div class="ent-sub">An uptime guarantee and security review in your contract</div>
|
||
</div>
|
||
</div>
|
||
<div class="ent-badges">
|
||
<span class="ent-badge">HANZO CLOUD</span>
|
||
<span class="ent-badge">YOUR VPC</span>
|
||
<span class="ent-badge">ON PREMISES</span>
|
||
<span class="ent-badge">AIR-GAPPED</span>
|
||
</div>
|
||
<div class="ent-metrics">
|
||
<div class="metric"><span class="m-val">Unlimited</span><span class="m-lbl">Members</span></div>
|
||
<div class="metric"><span class="m-val">Dedicated</span><span class="m-lbl">Compute</span></div>
|
||
<div class="metric"><span class="m-val">24/7</span><span class="m-lbl">Engineering support</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`
|
||
default:
|
||
throw new Error(`${card.name}: no visual named ${card.visual}`)
|
||
}
|
||
}
|
||
|
||
export function renderHtml(card) {
|
||
return `
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta charset="utf-8"/>
|
||
<style>
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body {
|
||
width: 1200px;
|
||
height: 630px;
|
||
background-color: #070709;
|
||
color: #fafafa;
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
||
overflow: hidden;
|
||
position: relative;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: space-between;
|
||
padding: 56px 64px;
|
||
}
|
||
|
||
/* Ambient background grid */
|
||
.bg-grid {
|
||
position: absolute;
|
||
inset: 0;
|
||
background-image:
|
||
linear-gradient(to right, rgba(255, 255, 255, 0.04) 1px, transparent 1px),
|
||
linear-gradient(to bottom, rgba(255, 255, 255, 0.04) 1px, transparent 1px);
|
||
background-size: 48px 48px;
|
||
mask-image: radial-gradient(ellipse 90% 70% at 50% 0%, black 20%, transparent 80%);
|
||
pointer-events: none;
|
||
z-index: 0;
|
||
}
|
||
|
||
/* Top radial aura */
|
||
.bg-aura {
|
||
position: absolute;
|
||
top: -120px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
width: 900px;
|
||
height: 480px;
|
||
background: radial-gradient(ellipse 60% 50% at 50% 0%, rgba(${card.accentRgb}, 0.22) 0%, rgba(${card.accentRgb}, 0.05) 55%, transparent 80%);
|
||
filter: blur(64px);
|
||
pointer-events: none;
|
||
z-index: 0;
|
||
}
|
||
|
||
.header {
|
||
position: relative;
|
||
z-index: 10;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
.brand {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
}
|
||
.brand-name {
|
||
font-size: 26px;
|
||
font-weight: 700;
|
||
letter-spacing: -0.02em;
|
||
color: #ffffff;
|
||
}
|
||
.url-tag {
|
||
font-family: ui-monospace, Menlo, monospace;
|
||
font-size: 15px;
|
||
color: #a1a1aa;
|
||
padding: 6px 14px;
|
||
background: rgba(255, 255, 255, 0.05);
|
||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||
border-radius: 9999px;
|
||
}
|
||
|
||
.main {
|
||
position: relative;
|
||
z-index: 10;
|
||
display: grid;
|
||
grid-template-columns: 560px 480px;
|
||
gap: 32px;
|
||
align-items: center;
|
||
margin-block: auto;
|
||
}
|
||
|
||
.eyebrow {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 5px 14px;
|
||
border-radius: 9999px;
|
||
background: rgba(255, 255, 255, 0.05);
|
||
border: 1px solid rgba(${card.accentRgb}, 0.35);
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.12em;
|
||
text-transform: uppercase;
|
||
color: ${card.accent};
|
||
margin-bottom: 20px;
|
||
}
|
||
.eyebrow-pip {
|
||
width: 7px;
|
||
height: 7px;
|
||
border-radius: 9999px;
|
||
background-color: ${card.accent};
|
||
box-shadow: 0 0 10px ${card.accent};
|
||
}
|
||
|
||
.title {
|
||
font-size: 54px;
|
||
font-weight: 800;
|
||
line-height: 1.12;
|
||
letter-spacing: -0.035em;
|
||
color: #ffffff;
|
||
margin-bottom: 18px;
|
||
}
|
||
|
||
.lede {
|
||
font-size: 21px;
|
||
line-height: 1.5;
|
||
color: #a1a1aa;
|
||
margin-bottom: 28px;
|
||
max-width: 520px;
|
||
}
|
||
|
||
.pills {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
}
|
||
.pill {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: #d4d4d8;
|
||
background: rgba(255, 255, 255, 0.03);
|
||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||
padding: 6px 14px;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
/* Mockups */
|
||
.mockup-terminal, .mockup-rows, .mockup-code, .mockup-enterprise, .mockup-pricing {
|
||
position: relative;
|
||
width: 100%;
|
||
}
|
||
.card-glow {
|
||
position: absolute;
|
||
inset: -12px;
|
||
background: radial-gradient(circle at 50% 50%, rgba(var(--glow-rgb), 0.25) 0%, transparent 70%);
|
||
filter: blur(28px);
|
||
z-index: 1;
|
||
}
|
||
|
||
.window-shell {
|
||
position: relative;
|
||
z-index: 2;
|
||
background: #111115;
|
||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||
border-radius: 16px;
|
||
overflow: hidden;
|
||
box-shadow: 0 20px 50px rgba(0,0,0,0.6), 0 0 0 1px rgba(var(--glow-rgb), 0.2);
|
||
}
|
||
.window-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 12px 16px;
|
||
background: #18181c;
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||
}
|
||
.dot { width: 10px; height: 10px; border-radius: 9999px; }
|
||
.dot.red { background: #ef4444; }
|
||
.dot.yellow { background: #eab308; }
|
||
.dot.green { background: #22c55e; }
|
||
.window-title {
|
||
font-family: ui-monospace, Menlo, monospace;
|
||
font-size: 12px;
|
||
color: #71717a;
|
||
margin-left: 6px;
|
||
}
|
||
|
||
.k { color: #f43f5e; font-weight: 600; }
|
||
.f { color: #38bdf8; }
|
||
.s { color: #a3e635; }
|
||
|
||
/* Terminal */
|
||
.terminal-shell { background: #0c0c0e; }
|
||
.terminal-content {
|
||
padding: 20px;
|
||
font-family: ui-monospace, Menlo, monospace;
|
||
font-size: 13.5px;
|
||
line-height: 1.6;
|
||
}
|
||
.line { color: #a1a1aa; }
|
||
.line.cmd { color: #ffffff; font-weight: 600; }
|
||
.line.dim { color: #71717a; }
|
||
.line.out.link { color: #38bdf8; text-decoration: underline; text-underline-offset: 3px; }
|
||
.line.success { color: #34d399; font-weight: 600; margin-top: 4px; }
|
||
.prompt { color: #38bdf8; margin-right: 6px; }
|
||
.cursor {
|
||
display: inline-block;
|
||
width: 8px;
|
||
height: 15px;
|
||
background: #ffffff;
|
||
vertical-align: middle;
|
||
margin-left: 4px;
|
||
}
|
||
|
||
/* Pricing */
|
||
.mockup-pricing {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 16px;
|
||
position: relative;
|
||
z-index: 2;
|
||
}
|
||
.pricing-card {
|
||
background: #111115;
|
||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||
border-radius: 14px;
|
||
padding: 22px 18px;
|
||
}
|
||
.pricing-card.popular {
|
||
border-color: rgba(var(--glow-rgb), 0.5);
|
||
background: #14141a;
|
||
box-shadow: 0 0 25px rgba(var(--glow-rgb), 0.2);
|
||
position: relative;
|
||
}
|
||
.plan-badge { font-size: 12px; font-weight: 700; color: #a1a1aa; letter-spacing: 0.08em; }
|
||
.plan-price { font-size: 34px; font-weight: 800; color: #fff; margin-block: 6px; }
|
||
.plan-price span { font-size: 14px; font-weight: 400; color: #a1a1aa; }
|
||
.plan-features div { font-size: 12.5px; color: #d4d4d8; margin-bottom: 6px; }
|
||
|
||
/* Models */
|
||
|
||
/* Code */
|
||
.code-content {
|
||
padding: 20px;
|
||
font-family: ui-monospace, Menlo, monospace;
|
||
font-size: 13.5px;
|
||
line-height: 1.6;
|
||
color: #e4e4e7;
|
||
}
|
||
|
||
/* Enterprise */
|
||
.enterprise-box {
|
||
position: relative;
|
||
z-index: 2;
|
||
background: #111115;
|
||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||
border-radius: 16px;
|
||
padding: 24px;
|
||
box-shadow: 0 16px 40px rgba(0,0,0,0.5);
|
||
}
|
||
.ent-header { display: flex; gap: 14px; align-items: center; margin-bottom: 18px; }
|
||
.ent-shield { font-size: 32px; }
|
||
.ent-title { font-size: 17px; font-weight: 700; color: #fff; }
|
||
.ent-sub { font-size: 12.5px; color: #a1a1aa; }
|
||
.ent-badges { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 20px; }
|
||
.ent-badge { font-size: 11px; font-weight: 700; color: #fbbf24; background: rgba(245, 158, 11, 0.1); border: 1px solid rgba(245, 158, 11, 0.25); padding: 4px 10px; border-radius: 6px; }
|
||
.sec-box { border-color: rgba(59, 130, 246, 0.35); }
|
||
.sec-badges .ent-badge { color: #93c5fd; background: rgba(59, 130, 246, 0.1); border-color: rgba(59, 130, 246, 0.25); }
|
||
.ent-metrics { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; border-top: 1px solid rgba(255, 255, 255, 0.08); padding-top: 16px; }
|
||
.metric { text-align: center; }
|
||
.m-val { display: block; font-size: 20px; font-weight: 800; color: #fff; }
|
||
.m-lbl { font-size: 11px; color: #71717a; }
|
||
|
||
.rows { padding: 8px 22px 10px; }
|
||
.row { display: flex; flex-direction: column; gap: 4px; padding: 14px 0; border-bottom: 1px solid rgba(255, 255, 255, 0.07); }
|
||
.row:last-child { border-bottom: none; }
|
||
.row-key { font-size: 16px; font-weight: 600; color: #fff; }
|
||
.row-key.mono { font-family: ui-monospace, Menlo, monospace; font-size: 15px; color: rgb(var(--glow-rgb)); }
|
||
.row-value { font-size: 14px; color: #a1a1aa; }
|
||
|
||
.footer {
|
||
position: relative;
|
||
z-index: 10;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
border-top: 1px solid rgba(255, 255, 255, 0.07);
|
||
padding-top: 18px;
|
||
font-size: 14px;
|
||
color: #71717a;
|
||
}
|
||
${card.css ?? ''}</style>
|
||
</head>
|
||
<body>
|
||
<div class="bg-grid"></div>
|
||
<div class="bg-aura"></div>
|
||
|
||
<div class="header">
|
||
<div class="brand">
|
||
${HANZO_MARK_SVG}
|
||
<span class="brand-name">Hanzo AI</span>
|
||
</div>
|
||
<div class="url-tag">hanzo.ai</div>
|
||
</div>
|
||
|
||
<div class="main">
|
||
<div class="content">
|
||
<div class="eyebrow">
|
||
<span class="eyebrow-pip"></span>
|
||
${card.badge}
|
||
</div>
|
||
<h1 class="title">${card.title}</h1>
|
||
<p class="lede">${card.lede}</p>
|
||
<div class="pills">
|
||
${card.pills.map(p => `<span class="pill">${p}</span>`).join('')}
|
||
</div>
|
||
</div>
|
||
<div class="visual">
|
||
${renderVisual(card)}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="footer">
|
||
<span>The AI cloud and app builder</span>
|
||
<span>Chat · Dev · API · Hanzo OS</span>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
`
|
||
}
|
||
|
||
async function run() {
|
||
// Loaded here, not above: e2e/unit/cards.spec.ts imports this file for its words and draws nothing.
|
||
const { default: sharp } = await import('sharp')
|
||
const browser = await chromium.launch({ headless: true })
|
||
const page = await browser.newPage({ viewport: { width: 1200, height: 630 }, deviceScaleFactor: 2 })
|
||
const drawn = existsSync(MANIFEST) ? JSON.parse(readFileSync(MANIFEST, 'utf8')) : {}
|
||
const only = process.argv.slice(2)
|
||
for (const card of only.length ? CARDS.filter((c) => only.includes(c.name)) : CARDS) {
|
||
const html = renderHtml(card)
|
||
await page.setContent(html, { waitUntil: 'load' })
|
||
const path = fileURLToPath(new URL(`../${file(card.name)}`, import.meta.url))
|
||
// A palette of 256, dithered: a dark card with soft light looks the same at
|
||
// about a third of the bytes, and a share card stays a PNG.
|
||
const png = await sharp(await page.screenshot({ type: 'png' }))
|
||
.png({ palette: true, quality: 90, dither: 1, effort: 10, compressionLevel: 9 })
|
||
.toBuffer()
|
||
writeFileSync(path, png)
|
||
drawn[card.name] = { html: sha(html), png: sha(png) }
|
||
console.log(`drew ${file(card.name)}`)
|
||
}
|
||
await browser.close()
|
||
const names = CARDS.map((c) => c.name)
|
||
writeFileSync(MANIFEST, `${JSON.stringify(Object.fromEntries(names.filter((n) => drawn[n]).map((n) => [n, drawn[n]])), null, 2)}\n`)
|
||
}
|
||
|
||
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href)
|
||
run().catch((err) => {
|
||
console.error(err)
|
||
process.exit(1)
|
||
})
|