Files
zeekay edb6963531 Plans, prices and the model table are read from api.hanzo.ai when a page opens, and no page carries a copy
The root layout handed every page the plan catalog (lib/rooms.ts configure
with lib/data/plans.json), and /pricing, /calculator, /customers and the
benchmark pages bundled the 202 KB price list. lib/api is now the one reader:
plans() GET /v1/billing/plans, pricing() /v1/pricing, summary(), served()
/v1/models, templates(), blueprints() /v1/blueprint/sbom, one promise a path a
page load, each drawn as loading, ready or "api.hanzo.ai is unreachable"
(components/api) and never an older number. plans.json, pricing.json,
models.json, templates.json, blueprints.json and sync-pricing, sync-models,
sync-templates, sync-blueprints are gone.

What a static export must know before anyone asks, which model pages exist and
their titles, is read by the build itself (lib/api/build, Next's fetch cache
keyed by the build's stamp: one answer per build) and renders no rate.
lib/moved reads the retired model pages against the export, and the 153 pages
the old snapshot kept for models the gateway dropped are in retired.json.
Kai's price beside Jev's, Enso's tier rates, a model page's rate and the GPU
table read the price list in the browser; the site's plan math no longer
imports @hanzo/rooms/lib/plans, whose bundled @hanzo/plans catalog rode the
home, /pricing and the picker.

Home first load 452.7 -> 437.4 KB gzip (budget 440); pages over
perf.budget.json 32 -> 9, the rest over for settings code, connectors and the
commerce catalog. The gates serve invented answers (e2e/gates/api) and block
them; e2e/gates/read.spec.ts holds that no script carries a plan, price or
model table.
2026-10-15 11:07:56 -07:00

220 lines
8.6 KiB
TypeScript

'use client'
import type { ComponentProps, ReactNode } from 'react'
import { Section } from '@/components/marketing/page-kit'
import { useBlueprints } from '@/lib/api/use'
import { Ready } from '@/components/api/state'
import type { Blueprints } from '@/lib/api/read'
import { Box, Text, View } from '@hanzo/ui'
import { tracks } from '@hanzo/ui/grid'
/**
* What the same software costs when you run it.
*
* EVERY FIGURE IS THE API'S. `apps/blueprint` parses each stack's own
* docker-compose, sums what its services reserve, and prices that through one
* rate card it publishes with the answer; the page reads it when it opens
* (GET /v1/blueprint/sbom, lib/api). Nothing here is typed by hand or baked, so
* the page cannot disagree with the endpoint a customer can call themselves.
*
* WHAT THIS PAGE MAY NOT DO is win by arithmetic. The honest comparison is
* compute against compute — a figure both sides can check — and then the parts
* that are real and NOT in the number, said plainly rather than left for the
* reader to discover. A page that added an invented "engineer-hours" line to
* make the total look better would be the fabricated number the blueprint plane
* exists to avoid, and nobody believes it anyway.
*
* So: the machines are priced, the operating is described, and the reader does
* their own sum. That is the strongest version of this argument that is also
* true.
*/
const usd = (cents: number) =>
`$${(cents / 100).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
/* ── the ledger ────────────────────────────────────────────────────────────────
A real `<table>`, stated a table: a gui Text is an inline box and a View a
flex column until told, so each element names its table display on the web.
Every size carries its leading — a rung named without one leaves the line box
to whatever an ancestor sets, which is how one row ends up a pixel and a half
taller than the one beside it. A cell keeps the browser's 1px inset. */
type TextProps = ComponentProps<typeof Text>
/** `--text-sm` at its own leading. */
const SMALL = { fontSize: '$2', lineHeight: '1.1607rem' } as const
function Row({ head, last, children }: { head?: boolean; last?: boolean; children: ReactNode }) {
return (
<Text
render="tr"
borderBottomWidth={last ? 0 : 1}
borderColor={head ? 'var(--neutral-800)' : 'var(--neutral-900)'}
color={head ? 'var(--muted-foreground)' : undefined}
$platform-web={{ display: 'table-row' }}
>
{children}
</Text>
)
}
/** A cell. `num` sets a figure flush right in lining digits; `last` is the last column, with no gutter after it. */
function Cell({
th,
num,
last,
colSpan,
children,
...p
}: { th?: boolean; num?: boolean; last?: boolean; colSpan?: number; children: ReactNode } & Omit<TextProps, 'children'>) {
return (
<Text
render={th ? 'th' : <td colSpan={colSpan} />}
py={12}
pl={1}
pr={last ? 1 : 16}
text={num ? 'right' : 'left'}
{...SMALL}
fontWeight={th ? '500' : undefined}
{...p}
$platform-web={{ display: 'table-cell', ...(num ? { fontVariantNumeric: 'tabular-nums' } : null) }}
>
{children}
</Text>
)
}
/** The paragraph that carries a number's basis. */
function Basis({ top, children }: { top: number; children: ReactNode }) {
return (
<Text render="p" display="block" mt={top} fontSize="$2" lineHeight="1.25rem" color="var(--muted-foreground)">
{children}
</Text>
)
}
/** What you are still holding after the machines are paid for. */
const OPERATING = [
['Upgrades', 'Every image above has a release cadence, and a CVE in any of them is yours to notice and roll.'],
['Backups', 'Taking one is a cron. Proving a restore works is a rehearsal, and it is the half that gets skipped.'],
['Availability', 'One replica of each is a single point of failure. A second is roughly twice the compute plus the state problem.'],
['Identity', 'Each of these has its own users and its own idea of an admin. Joining them is a project.'],
['On-call', 'Someone answers when it pages at 3am, and that person is on your payroll.'],
]
export default function Comparison() {
const reading = useBlueprints()
return (
<>
<Section
title="Run it yourself, and here is the bill"
lede="The open-source stacks people reach for, priced from the compute their own compose files reserve."
>
<Ready reading={reading} what="the stacks and what they cost">
{(doc) => <Bill doc={doc} />}
</Ready>
</Section>
<Operating />
</>
)
}
/** The priced stacks, their total, and the basis the rate card states. */
function Bill({ doc }: { doc: Blueprints }) {
const rows = doc.rows
const total = rows.reduce((n, b) => n + b.centsPerMonth, 0)
return (
<>
{/* The five columns need 576px before they stop being readable, and
this wrapper scrolls rather than letting them crush. */}
<Box overflowX="auto">
<Text render="table" width="100%" minW={576} text="left" {...SMALL} $platform-web={{ display: 'table', borderCollapse: 'collapse' }}>
<thead>
<Row head>
<Cell th>Stack</Cell>
<Cell th>Images it runs</Cell>
<Cell th num>
vCPU
</Cell>
<Cell th num>
GB
</Cell>
<Cell th num last>
Compute
</Cell>
</Row>
</thead>
<tbody>
{rows.map((b) => (
<Row key={b.id}>
<Cell fontWeight="500" color="var(--foreground)">
{b.id}
</Cell>
<Cell fontFamily="$mono" fontSize="$1" lineHeight="0.9167rem" color="var(--muted-foreground)">
{b.images.join(' · ')}
</Cell>
<Cell num color="var(--muted-foreground)">
{b.vcpuHr}
</Cell>
<Cell num color="var(--muted-foreground)">
{b.gbHr}
</Cell>
<Cell num last color="var(--text-secondary)">
{usd(b.centsPerMonth)}/mo
</Cell>
</Row>
))}
<Row last>
<Cell colSpan={4} fontWeight="500" color="var(--foreground)">
All of them, running
</Cell>
<Cell num last fontWeight="500" color="var(--foreground)">
{usd(total)}/mo
</Cell>
</Row>
</tbody>
</Text>
</Box>
{/* The basis travels with the number, because a cost a reader cannot
check is a number they are asked to take on faith. */}
<Basis top={24}>
Compute only, from each stack&rsquo;s own compose file: {doc.basis}. One replica of each,
at the footprint the file reserves. Storage, egress and anything with a GPU are not in it —
serving a model is a GPU, and no compose file above asks for one.
</Basis>
</>
)
}
/** What running them takes that is not a line item. */
function Operating() {
return (
<Section
title="And the part that is not a line item"
lede="The machines are the cheap half. This is what you are holding once they are paid for."
>
{/* `tracks` rather than a breakpoint: the fact is about the TERM — it
needs 280px before its definition stops reading as a column — and
auto-fill counts how many of those fit whatever measure the section
hands it, which no viewport query can. */}
<View render="dl" display="grid" gridTemplateColumns={tracks({ min: 280, max: 2 }, '40px')} columnGap={40} rowGap={24} my={14}>
{OPERATING.map(([term, def]) => (
<Box key={term}>
<Text render="dt" display="block" {...SMALL} fontWeight="500" color="var(--foreground)">
{term}
</Text>
<Text render="dd" display="block" mt={6} ml={40} fontSize="$2" lineHeight="1.25rem" color="var(--muted-foreground)">
{def}
</Text>
</Box>
))}
</View>
<Basis top={32}>
We have not costed that list, and will not: an invented engineer-hours figure would be
worth less than the arithmetic you can do yourself, knowing your own team. What Hanzo
replaces is the whole column — one identity, one bill, one thing that pages someone else.
</Basis>
</Section>
)
}