mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
The owner's spec for a landing page is the models bento's: monochrome, the words left and the art right, no extra prose. The folds carried two to four sentences under each heading, and some a claim, a lede and a list besides. Each now says one line: ProductLanding the tagline where a page states one (the lede still describes it to search), a claim alone where the claim is the line (/payments, /treasury, /zap, the extension), and elsewhere the sentence that says what the thing is. /philosophy's two points, /dns' claim that repeated its title, /calculator's capabilities line and the Spare clauses on /research and /learn go.
965 lines
45 KiB
TypeScript
965 lines
45 KiB
TypeScript
"use client"
|
||
|
||
import { useMemo, useState, type ReactNode } from 'react'
|
||
import dynamic from 'next/dynamic'
|
||
import { Text, View, XStack, YStack } from '@hanzo/ui'
|
||
import { Models } from '@/components/api/count'
|
||
import { phrase } from '@hanzo/i18n'
|
||
import { useI18n } from '@hanzo/i18n/react'
|
||
import { Fold } from '@/components/marketing/fold'
|
||
|
||
// The token calculator stands below the first screen: its script loads after it.
|
||
const Savings = dynamic(() => import('@/components/sales/Savings'))
|
||
|
||
// True Savings — an operating-cost model, not a TAM calculator. Every number on
|
||
// this page is derived from a visible, editable control; nothing is hardcoded
|
||
// into the headline. Two axes are kept deliberately separate: COVERAGE (how much
|
||
// of a category Hanzo replaces, which carries weight) and EVIDENCE (how well we
|
||
// can prove it, which carries none). Collapsing them would let an attestation
|
||
// read as a verification, which is the one thing this page must never do.
|
||
|
||
const FTE = 220_000 // fully loaded engineer, used for both glue reclaimed and Hanzo ops
|
||
const REF_E = 100 // reference deployment the analyst priced against
|
||
const REF_D = 30
|
||
|
||
type Cov = 'full' | 'partial' | 'experimental' | 'roadmap'
|
||
type Ev = 'verified' | 'gap' | 'internal' | 'attested' | 'repo'
|
||
|
||
const WEIGHT: Record<Cov, number> = { full: 1, partial: 0.5, experimental: 0.25, roadmap: 0 }
|
||
const COV_LABEL: Record<Cov, string> = {
|
||
full: phrase('full'),
|
||
partial: phrase('partial 50%'),
|
||
experimental: phrase('experimental 25%'),
|
||
roadmap: phrase('roadmap 0%'),
|
||
}
|
||
const EV_LABEL: Record<Ev, string> = {
|
||
verified: phrase('verified'),
|
||
gap: phrase('verified gap'),
|
||
internal: phrase('internal production'),
|
||
attested: phrase('management-attested'),
|
||
repo: phrase('repository-verified'),
|
||
}
|
||
const COV_CYCLE: Cov[] = ['full', 'partial', 'experimental', 'roadmap']
|
||
|
||
// Billing basis, so the model still means something away from the reference
|
||
// deployment: per-seat rows scale on employees, per-engineer rows on engineers,
|
||
// platform-fee rows not at all. At E=100/30% every factor is 1.0, which is what
|
||
// reproduces the analyst's $613k software total exactly.
|
||
type Basis = 's' | 'e' | 'f'
|
||
|
||
type Row = {
|
||
name: string
|
||
replaces: string
|
||
hanzo: string
|
||
k: number // analyst default, $k/yr at the reference deployment
|
||
basis: Basis
|
||
cov: Cov
|
||
ev: Ev
|
||
}
|
||
|
||
const ROWS: Row[] = [
|
||
{ name: phrase('SSO & workforce identity'), replaces: 'Okta / Auth0', hanzo: 'iam · zero-trust · authz', k: 10, basis: 's', cov: 'full', ev: 'internal' },
|
||
{ name: phrase('Customer identity (product)'), replaces: 'Auth0 B2C', hanzo: 'iam (multi-tenant, per-org CEK)', k: 14, basis: 'f', cov: 'full', ev: 'internal' },
|
||
{ name: phrase('Secrets & KMS'), replaces: 'Vault Cloud', hanzo: 'kms', k: 11, basis: 'e', cov: 'full', ev: 'internal' },
|
||
{ name: phrase('Observability'), replaces: 'Datadog / New Relic', hanzo: 'o11y · metrics', k: 54, basis: 'e', cov: 'full', ev: 'internal' },
|
||
{ name: phrase('Error tracking'), replaces: 'Sentry', hanzo: 'o11y (errortracking)', k: 9, basis: 'e', cov: 'full', ev: 'internal' },
|
||
{ name: phrase('Feature flags'), replaces: 'LaunchDarkly', hanzo: 'flags', k: 7, basis: 'e', cov: 'full', ev: 'repo' },
|
||
{ name: phrase('Product analytics'), replaces: 'Amplitude / Mixpanel', hanzo: 'analytics', k: 15, basis: 'f', cov: 'full', ev: 'internal' },
|
||
{ name: phrase('Event routing / CDP'), replaces: 'Segment', hanzo: 'destinations · tracker', k: 22, basis: 'f', cov: 'full', ev: 'repo' },
|
||
{ name: phrase('Git hosting'), replaces: 'GitHub Enterprise', hanzo: 'git (git.hanzo.ai)', k: 8, basis: 'e', cov: 'full', ev: 'internal' },
|
||
{ name: phrase('CI/CD'), replaces: 'CircleCI / Actions', hanzo: 'hanzoai/ci · arc runners', k: 18, basis: 'e', cov: 'full', ev: 'internal' },
|
||
{ name: phrase('Container registry'), replaces: 'ECR / Docker Hub', hanzo: 'registry (oci.hanzo.ai)', k: 4, basis: 'e', cov: 'full', ev: 'internal' },
|
||
{ name: phrase('PaaS / app hosting'), replaces: 'Vercel / Heroku', hanzo: 'paas · deploy · functions', k: 13, basis: 'e', cov: 'full', ev: 'internal' },
|
||
{ name: phrase('App backend / BaaS'), replaces: 'Supabase / Firebase', hanzo: 'base · docdb · orm', k: 15, basis: 'f', cov: 'full', ev: 'internal' },
|
||
{ name: phrase('Queues & streaming'), replaces: 'Confluent / SQS', hanzo: 'pubsub (NATS) · kafka', k: 10, basis: 'f', cov: 'full', ev: 'repo' },
|
||
{ name: phrase('AI gateway & routing'), replaces: 'Portkey / OpenRouter', hanzo: 'ai · zen · gateway', k: 12, basis: 'f', cov: 'full', ev: 'internal' },
|
||
{ name: phrase('Workflow automation'), replaces: 'Zapier', hanzo: 'automations · connectorruntime · cron', k: 12, basis: 's', cov: 'full', ev: 'repo' },
|
||
{ name: phrase('Billing & metering'), replaces: 'Chargebee / Metronome', hanzo: 'billing · usage · entitlements · pricing', k: 18, basis: 'f', cov: 'full', ev: 'internal' },
|
||
{ name: phrase('Commerce'), replaces: 'Shopify Plus', hanzo: 'commerce · marketplace', k: 24, basis: 'f', cov: 'full', ev: 'repo' },
|
||
{ name: phrase('E-signature'), replaces: 'DocuSign', hanzo: 'sign', k: 2, basis: 's', cov: 'full', ev: 'repo' },
|
||
{ name: phrase('Cap table'), replaces: 'Carta', hanzo: 'captable', k: 5, basis: 'f', cov: 'full', ev: 'internal' },
|
||
{ name: phrase('Data rooms'), replaces: 'DocSend / iDeals', hanzo: 'dataroom', k: 6, basis: 'f', cov: 'full', ev: 'repo' },
|
||
{ name: phrase('Referrals & affiliates'), replaces: 'PartnerStack', hanzo: 'affiliates · referrals · link', k: 9, basis: 'f', cov: 'full', ev: 'repo' },
|
||
{ name: phrase('Experiments & evals'), replaces: 'Statsig / Optimizely', hanzo: 'experiments · evals', k: 12, basis: 'f', cov: 'full', ev: 'repo' },
|
||
{ name: phrase('Zero-trust access'), replaces: 'Tailscale / CF Access', hanzo: 'zero-trust · ingress', k: 7, basis: 's', cov: 'full', ev: 'internal' },
|
||
{ name: phrase('Search'), replaces: 'Algolia / Elastic', hanzo: 'index · websearch', k: 12, basis: 'f', cov: 'full', ev: 'repo' },
|
||
{ name: phrase('Vector store'), replaces: 'Pinecone', hanzo: 'knowledge', k: 8, basis: 'f', cov: 'partial', ev: 'gap' },
|
||
{ name: phrase('Compliance automation'), replaces: 'Vanta / Drata', hanzo: 'compliance · audit · sbom · security', k: 18, basis: 'f', cov: 'full', ev: 'repo' },
|
||
{ name: phrase('CRM'), replaces: 'Salesforce / HubSpot', hanzo: 'crm · company', k: 27, basis: 's', cov: 'full', ev: 'internal' },
|
||
{ name: phrase('Marketing automation'), replaces: 'HubSpot Marketing', hanzo: 'marketing · campaign', k: 10, basis: 'f', cov: 'full', ev: 'repo' },
|
||
{ name: phrase('Projects & tasks'), replaces: 'Linear / Asana', hanzo: 'projects · tasks', k: 14, basis: 's', cov: 'full', ev: 'internal' },
|
||
{ name: phrase('Knowledge & docs'), replaces: 'Notion / Confluence', hanzo: 'knowledge · guide', k: 12, basis: 's', cov: 'full', ev: 'internal' },
|
||
{ name: phrase('Support & messaging'), replaces: 'Intercom / Zendesk', hanzo: 'help · ask · channels · bots', k: 11, basis: 's', cov: 'full', ev: 'internal' },
|
||
{ name: phrase('Notifications & email'), replaces: 'SendGrid / Customer.io', hanzo: 'notify', k: 7, basis: 'f', cov: 'full', ev: 'internal' },
|
||
{ name: phrase('Incident & status'), replaces: 'PagerDuty / Statuspage', hanzo: 'o11y (incident · status)', k: 11, basis: 'e', cov: 'full', ev: 'internal' },
|
||
{ name: phrase('Finance ops & ledger'), replaces: phrase('NetSuite add-ons'), hanzo: 'books · treasury · ledger-fi', k: 6, basis: 'f', cov: 'full', ev: 'internal' },
|
||
{ name: phrase('Design & creative'), replaces: 'Figma / Canva', hanzo: 'studio (studio.hanzo.ai)', k: 16, basis: 's', cov: 'full', ev: 'repo' },
|
||
{ name: phrase('Video, calls & calendar'), replaces: 'Zoom / Calendly', hanzo: 'team (calls · calendar)', k: 18, basis: 's', cov: 'full', ev: 'internal' },
|
||
{ name: phrase('Office suite & drive'), replaces: 'Google Workspace', hanzo: 'office · drive', k: 14, basis: 's', cov: 'full', ev: 'internal' },
|
||
{ name: phrase('HR & payroll'), replaces: 'Gusto / Rippling', hanzo: 'hr · payroll', k: 48, basis: 's', cov: 'full', ev: 'internal' },
|
||
{ name: phrase('Payment orchestration'), replaces: phrase('PSP markup over interchange+'), hanzo: 'psp · x402 · wallets', k: 12, basis: 'f', cov: 'full', ev: 'repo' },
|
||
{ name: phrase('ERP / CMS / business apps'), replaces: 'NetSuite / WordPress', hanzo: 'erp · base', k: 20, basis: 'f', cov: 'full', ev: 'internal' },
|
||
{ name: phrase('LLM inference & routing'), replaces: 'OpenAI / Anthropic API', hanzo: 'enso (routing) · zen (self-hosted)', k: 36, basis: 'e', cov: 'full', ev: 'attested' },
|
||
]
|
||
|
||
const AI_ROW = ROWS.length - 1
|
||
|
||
// One chip per vendor that shows up on a real bill — several per category. A
|
||
// category counts while any of its vendors is selected; the counter counts
|
||
// vendors, not categories. 107 chips, which is the same 107 dependencies the
|
||
// reference deployment consolidated.
|
||
const VENDORS: [string, number][] = [
|
||
['Okta', 0], ['Auth0', 0], ['JumpCloud', 0], ['Clerk', 1], ['Firebase Auth', 1], ['Vault', 2], ['AWS KMS', 2],
|
||
['Doppler', 2], ['Datadog', 3], ['New Relic', 3], ['Grafana Cloud', 3], ['Splunk', 3], ['Sentry', 4], ['Rollbar', 4],
|
||
['LaunchDarkly', 5], ['Split', 5], ['Amplitude', 6], ['Mixpanel', 6], ['Heap', 6], ['Segment', 7], ['RudderStack', 7],
|
||
['GitHub', 8], ['GitLab', 8], ['Bitbucket', 8], ['CircleCI', 9], ['Buildkite', 9], ['Docker Hub', 10], ['JFrog', 10],
|
||
['Vercel', 11], ['Netlify', 11], ['Heroku', 11], ['Fly.io', 11], ['Supabase', 12], ['Firebase', 12], ['Confluent', 13],
|
||
['Redpanda', 13], ['Portkey', 14], ['OpenRouter', 14], ['Zapier', 15], ['Make', 15], ['n8n cloud', 15],
|
||
['Chargebee', 16], ['Metronome', 16], ['Stripe Billing', 16], ['Shopify', 17], ['BigCommerce', 17], ['DocuSign', 18],
|
||
['Dropbox Sign', 18], ['Carta', 19], ['Pulley', 19], ['DocSend', 20], ['iDeals', 20], ['PartnerStack', 21],
|
||
['Rewardful', 21], ['Statsig', 22], ['Optimizely', 22], ['Tailscale', 23], ['CF Access', 23], ['Algolia', 24],
|
||
['Elastic Cloud', 24], ['Pinecone', 25], ['Weaviate', 25], ['Vanta', 26], ['Drata', 26], ['Salesforce', 27],
|
||
['HubSpot CRM', 27], ['Pipedrive', 27], ['HubSpot Marketing', 28], ['Mailchimp', 28], ['Klaviyo', 28], ['Linear', 29],
|
||
['Asana', 29], ['Jira', 29], ['Monday', 29], ['Notion', 30], ['Confluence', 30], ['Zendesk', 31], ['Intercom', 31],
|
||
['Front', 31], ['SendGrid', 32], ['Twilio', 32], ['Customer.io', 32], ['PagerDuty', 33], ['Statuspage', 33],
|
||
['Opsgenie', 33], ['QuickBooks', 34], ['Bill.com', 34], ['Figma', 35], ['Canva', 35], ['Adobe CC', 35], ['Zoom', 36],
|
||
['Calendly', 36], ['Loom', 36], ['Google Workspace', 37], ['Microsoft 365', 37], ['Dropbox', 37], ['Gusto', 38],
|
||
['Rippling', 38], ['Deel', 38], ['Stripe', 39], ['Braintree', 39], ['NetSuite', 40], ['WordPress VIP', 40],
|
||
['Contentful', 40], ['OpenAI API', 41], ['Anthropic API', 41], ['Azure OpenAI', 41],
|
||
]
|
||
|
||
const SEAMS = [
|
||
phrase('auth bridges'), phrase('webhooks'), phrase('data sync'), phrase('billing reconciliation'), phrase('entitlements'), phrase('audit exports'),
|
||
phrase('pipelines'), phrase('vendor o11y'), phrase('incident routing'), phrase('schema transforms'), phrase('secret rotation'), phrase('API migrations'),
|
||
phrase('identity lifecycle'), phrase('model fallbacks'), phrase('security reviews'),
|
||
]
|
||
|
||
const SECOND_ORDER = [
|
||
phrase('SSO price premiums'), phrase('per-vendor security reviews'), phrase('DPAs'), phrase('procurement cycles'), phrase('duplicate observability'),
|
||
phrase('license reclamation'), phrase('credential sprawl'), phrase('integration attack surface'), phrase('onboarding speed'), phrase('unified policy'),
|
||
phrase('common metering'), phrase('model portability'), phrase('lock-in exposure'), phrase('air-gapped deployment'), phrase('incident response speed'),
|
||
phrase('fewer failure domains'),
|
||
]
|
||
|
||
const money = (n: number) => {
|
||
const a = Math.abs(n)
|
||
const sign = n < 0 ? '−' : ''
|
||
if (a >= 1e6) return `${sign}$${(a / 1e6).toFixed(3).replace(/\.?0+$/, '')}M`
|
||
return `${sign}$${Math.round(a / 1e3).toLocaleString('en-US')}k`
|
||
}
|
||
const dollars = (n: number) =>
|
||
`${n < 0 ? '−' : ''}$${Math.round(Math.abs(n)).toLocaleString('en-US')}`
|
||
|
||
export default function CalculatorPage() {
|
||
const { t } = useI18n()
|
||
const [E, setE] = useState(100)
|
||
const [Dp, setDp] = useState(30)
|
||
const [cloud, setCloud] = useState(15) // $k / month
|
||
const [infraPct, setInfraPct] = useState(60)
|
||
const [glueFte, setGlueFte] = useState(1.5)
|
||
const [opsFte, setOpsFte] = useState(0.5)
|
||
|
||
// The three deductions. Default 0 means "not included until entered" — the
|
||
// fully-adjusted tier stays explicitly incomplete rather than quietly equal
|
||
// to the run rate.
|
||
const [retained, setRetained] = useState(0)
|
||
const [fee, setFee] = useState(0)
|
||
const [migration, setMigration] = useState(0)
|
||
const [migYears, setMigYears] = useState(3)
|
||
|
||
const [cov, setCov] = useState<Cov[]>(() => ROWS.map((r) => r.cov))
|
||
const [price, setPrice] = useState<Record<number, number>>({}) // user overrides, $k
|
||
const [von, setVon] = useState<boolean[]>(() => VENDORS.map(() => true))
|
||
|
||
// AI sub-calculator — off by default so the headline stays the analyst's
|
||
// figure until the user prices their own bill.
|
||
const [aiOpen, setAiOpen] = useState(false)
|
||
const [aiOn, setAiOn] = useState(false)
|
||
const [ai, setAi] = useState({
|
||
spend: 10, // $k / month
|
||
routable: 60, // % of addressable spend that can move to a cheaper model
|
||
selfHost: 50, // % of routed spend that can run on Zen
|
||
ensoSave: 55, // % saved by routing alone
|
||
zenCost: 12, // Zen cost as % of frontier price
|
||
frontier: 25, // % that must stay frontier — pass-through, never counted
|
||
overhead: 10, // eval + fallback overhead against gross saving
|
||
gpuUtil: 70, // % GPU utilization
|
||
batch: 40, // % of self-hosted work that is batch
|
||
})
|
||
|
||
const D = Math.round((E * Dp) / 100)
|
||
|
||
const aiSaving = useMemo(() => {
|
||
const annual = ai.spend * 1000 * 12
|
||
const passthrough = annual * (ai.frontier / 100)
|
||
const addressable = annual - passthrough
|
||
const routed = addressable * (ai.routable / 100)
|
||
const hosted = routed * (ai.selfHost / 100)
|
||
const routedOnly = routed - hosted
|
||
// Low utilization raises effective self-hosted unit cost; batch work lowers it.
|
||
const zenRatio = (ai.zenCost / 100) * (70 / Math.max(ai.gpuUtil, 1)) * (1 - 0.25 * (ai.batch / 100))
|
||
const gross = hosted * Math.max(1 - zenRatio, 0) + routedOnly * (ai.ensoSave / 100)
|
||
return Math.max(gross * (1 - ai.overhead / 100), 0)
|
||
}, [ai])
|
||
|
||
const on = useMemo(
|
||
() => ROWS.map((_, i) => VENDORS.some(([, c], k) => c === i && von[k])),
|
||
[von],
|
||
)
|
||
|
||
const value = useMemo(
|
||
() =>
|
||
ROWS.map((r, i) => {
|
||
if (price[i] !== undefined) return price[i] * 1000
|
||
if (i === AI_ROW && aiOn) return aiSaving
|
||
const factor = r.basis === 's' ? E / REF_E : r.basis === 'e' ? D / REF_D : 1
|
||
return r.k * 1000 * factor
|
||
}),
|
||
[price, aiOn, aiSaving, E, D],
|
||
)
|
||
|
||
const software = useMemo(
|
||
() => value.reduce((sum, v, i) => (on[i] ? sum + v * WEIGHT[cov[i]] : sum), 0),
|
||
[value, on, cov],
|
||
)
|
||
|
||
const infra = cloud * 1000 * 12 * (infraPct / 100)
|
||
const glue = glueFte * FTE
|
||
const ops = opsFte * FTE
|
||
const gross = software + infra + glue
|
||
const runRate = gross - ops
|
||
const amortized = migration > 0 ? migration / Math.max(migYears, 1) : 0
|
||
const adjusted = runRate - retained - fee - amortized
|
||
const hasDeductions = retained > 0 || fee > 0 || migration > 0
|
||
const vendorCount = von.filter(Boolean).length
|
||
|
||
const cycleCov = (i: number) =>
|
||
setCov((c) => {
|
||
const next = [...c]
|
||
next[i] = COV_CYCLE[(COV_CYCLE.indexOf(c[i]) + 1) % COV_CYCLE.length]
|
||
return next
|
||
})
|
||
|
||
const toggleRow = (i: number, checked: boolean) =>
|
||
setVon((v) => v.map((s, k) => (VENDORS[k][1] === i ? checked : s)))
|
||
|
||
const setAllVendors = (v: boolean) => setVon(VENDORS.map(() => v))
|
||
|
||
const reset = () => {
|
||
setCov(ROWS.map((r) => r.cov))
|
||
setPrice({})
|
||
setVon(VENDORS.map(() => true))
|
||
setAiOn(false)
|
||
}
|
||
|
||
const num = (id: string, v: number, set: (n: number) => void, step = 1000) => (
|
||
<Field id={id} min={0} step={step} value={v || ''} placeholder="0" onChange={(n) => set(n)} />
|
||
)
|
||
|
||
return (
|
||
<YStack bg="var(--background)" $platform-web={{ color: 'var(--foreground)', lineHeight: '1.5' }}>
|
||
<Fold
|
||
label="Hanzo True Savings"
|
||
title="Price the assembly tax."
|
||
lede="It models operating expenses a company can eliminate, reduce, or reclaim."
|
||
>
|
||
{/* Your AI bill three ways, read from the price list when the page opens:
|
||
the first thing to use, under the words at the page's full measure. */}
|
||
<Savings />
|
||
</Fold>
|
||
<YStack width="100%" maxW="72rem" mx="auto" pt="2.5rem" px="1.25rem" pb="5rem" gap="2rem">
|
||
<Card render="section" accent>
|
||
<H2>{t('Reference deployment — Hanzo runs on Hanzo')}</H2>
|
||
<Note>
|
||
{t('Fully off GCP · Hanzo-managed infrastructure · AI, cloud, identity, observability, deploy, storage and business-ops native · eligible AI routed through Enso and self-hosted Zen · {consolidated} · ~90% lower compute and eligible-inference costs in selected workloads.', {
|
||
consolidated: <B>{t('107 vendor and managed-service dependencies consolidated into one plane')}</B>,
|
||
})}
|
||
</Note>
|
||
<Label>{t('Founder-attested.')}</Label>
|
||
</Card>
|
||
|
||
|
||
<YStack render="section">
|
||
<H2>{t('Your deployment')}</H2>
|
||
<Controls>
|
||
<Range id="ts-e" label={t('Employees')} shows={E} min={10} max={2000} step={10} value={E} onChange={setE} />
|
||
<Range id="ts-d" label={t('Engineers')} shows={`${Dp}% (${D})`} min={10} max={60} step={5} value={Dp} onChange={setDp} />
|
||
<Range id="ts-c" label={t('Cloud bill / month')} shows={`$${cloud}k`} min={0} max={250} step={5} value={cloud} onChange={setCloud} />
|
||
<Range id="ts-g" label={t('Integration-glue FTEs')} shows={glueFte} min={0} max={20} step={0.5} value={glueFte} onChange={setGlueFte} />
|
||
<Range id="ts-o" label={t('Platform-ops FTEs (running Hanzo)')} shows={opsFte} min={0} max={6} step={0.5} value={opsFte} onChange={setOpsFte} />
|
||
<Range id="ts-i" label={t('Infrastructure delta')} shows={`${infraPct}%`} min={0} max={90} step={5} value={infraPct} onChange={setInfraPct} />
|
||
</Controls>
|
||
</YStack>
|
||
|
||
<YStack render="section">
|
||
<H2>{t('Deductions — not included until entered')}</H2>
|
||
<Controls>
|
||
<Card>
|
||
<Caption id="ts-r">{t('Retained vendor spending ($/yr)')}</Caption>
|
||
{num('ts-r', retained, setRetained)}
|
||
</Card>
|
||
<Card>
|
||
<Caption id="ts-f">{t('Hanzo managed / support fee ($/yr)')}</Caption>
|
||
{num('ts-f', fee, setFee)}
|
||
</Card>
|
||
<Card>
|
||
<Caption id="ts-m">{t('One-time migration cost ($)')}</Caption>
|
||
{num('ts-m', migration, setMigration)}
|
||
</Card>
|
||
<Range id="ts-y" label={t('Amortized over')} shows={t('{n} yr', { n: migYears })} min={1} max={5} step={1} value={migYears} onChange={setMigYears} />
|
||
</Controls>
|
||
</YStack>
|
||
|
||
<YStack render="section">
|
||
<H2>{t('Results')}</H2>
|
||
<View display="grid" gridTemplateColumns="repeat(auto-fit, minmax(15rem, 1fr))" gap="0.9rem">
|
||
<Tier label={t('1 · Gross addressable savings')} note={t('Software + infrastructure + glue, before any deduction.')}>
|
||
<Big>{money(gross)}</Big>
|
||
</Tier>
|
||
<Tier lead label={t('2 · Annual run-rate savings')} note={t('Gross − Hanzo operating labor. This is the headline.')}>
|
||
<Big>{money(runRate)}</Big>
|
||
</Tier>
|
||
<Tier label={t('3 · Fully adjusted annual savings')} note={t('Run-rate − retained vendors − Hanzo fees − amortized migration.')}>
|
||
{hasDeductions ? (
|
||
<Big>{money(adjusted)}</Big>
|
||
) : (
|
||
<Text display="block" py="0.5rem" fontSize="1rem" lineHeight="1.2rem" color={WARN}>
|
||
{t('Customer input required')}
|
||
</Text>
|
||
)}
|
||
</Tier>
|
||
<Tier label={t('4 · Three-year economic value')} note={t('Fully adjusted × 3, with migration amortized across the horizon.')}>
|
||
<Big>{money(adjusted * 3)}</Big>
|
||
</Tier>
|
||
</View>
|
||
|
||
<View display="grid" gridTemplateColumns="repeat(auto-fit, minmax(11rem, 1fr))" gap="0.75rem" mt="0.9rem">
|
||
<Part label={t('Software replaced (selected rows)')}>{money(software)}</Part>
|
||
<Part label={t('Infrastructure delta')}>{money(infra)}</Part>
|
||
<Part label={t('Glue engineering reclaimed')}>{money(glue)}</Part>
|
||
<Part label={t('Cost of operating Hanzo')} cost>
|
||
−{money(ops)}
|
||
</Part>
|
||
<Part label={t('Per employee / yr (run-rate)')}>{dollars(runRate / Math.max(E, 1))}</Part>
|
||
<Part label={t('≈ senior engineers funded')}>{(runRate / FTE).toFixed(1)}</Part>
|
||
<Part label={t('Vendors collapsed')}>{vendorCount} → 1</Part>
|
||
</View>
|
||
|
||
<Text render="p" display="flex" flexWrap="wrap" gap="0.4rem" mt="0.9rem" fontSize="0.8rem" lineHeight={lh(0.8)} color={DIM}>
|
||
<Code>gross = software + infra + glue</Code> · <Code>run-rate = gross − ops</Code> ·{' '}
|
||
<Code>adjusted = run-rate − retained − fees − migration ÷ years</Code>
|
||
</Text>
|
||
<Text render="p" display="block" mt="0.5rem" fontSize="0.85rem" lineHeight={lh(0.85)} color={WARN}>
|
||
{t('Do not present {amount} as fully net until the final three deductions are entered.', { amount: money(runRate) })}
|
||
</Text>
|
||
|
||
<Text render="p" display="block" mt="1rem" maxW="min(80ch, 100%)" borderLeftWidth={2} borderColor="$edge" pl="0.9rem" fontSize="0.8rem" lineHeight={lh(0.8)} color={DIM}>
|
||
{t('Hanzo True Savings is an interactive operating-cost model, not a quote, guarantee, audit, or promise of results. Default prices are editable estimates based on representative technology stacks. Actual savings depend on existing contracts, usage, architecture, migration requirements, retained vendors, staffing, workload eligibility, and Hanzo deployment choices. Hanzo’s internal case-study figures are management-attested unless explicitly marked independently verified.')}
|
||
</Text>
|
||
</YStack>
|
||
|
||
<YStack render="section">
|
||
<H2>{t('Click everything you pay for')}</H2>
|
||
<Note>
|
||
{t('Every vendor maps to a line in the table. Tap the ones on your bill — the totals and the {counter} counter follow.', {
|
||
counter: <B>{t('vendors → 1')}</B>,
|
||
})}{' '}
|
||
<Toggle onPress={() => setAllVendors(true)}>{t('select all')}</Toggle>{' '}
|
||
<Toggle onPress={() => setAllVendors(false)}>{t('clear')}</Toggle>
|
||
</Note>
|
||
<XStack flexWrap="wrap" gap="0.35rem" mt="0.7rem">
|
||
{VENDORS.map(([name], k) => (
|
||
<Toggle key={name + k} pressed={von[k]} onPress={() => setVon((v) => v.map((s, j) => (j === k ? !s : s)))}>
|
||
{name}
|
||
</Toggle>
|
||
))}
|
||
</XStack>
|
||
</YStack>
|
||
|
||
<YStack render="section">
|
||
<H2>{t('Category by category')}</H2>
|
||
<Note>
|
||
{t('Two axes, kept separate. {coverage} carries weight and is clickable — full 100%, partial 50%, experimental 25%, roadmap 0%. {evidence} carries no weight. A service can have full coverage while still management-attested. Separating the axes prevents an attestation from being presented as verification.', {
|
||
coverage: <B>{t('Coverage')}</B>,
|
||
evidence: <B>{t('Evidence')}</B>,
|
||
})}
|
||
</Note>
|
||
<Note>
|
||
{t('Prices are the analyst defaults at the reference deployment (100 employees, 30% engineers) and are editable — type over any of them. Untouched rows scale on their billing basis: per-seat with employees, per-engineer with engineers, platform fees not at all.')}{' '}
|
||
<Toggle onPress={reset}>{t('Reset to analyst defaults')}</Toggle>
|
||
</Note>
|
||
<View overflowX="auto" bg="$panel" borderWidth={1} borderColor="$edge" mt="0.8rem">
|
||
<Text render="table" width="100%" fontSize="0.85rem" lineHeight={lh(0.85)} color="$ink" $platform-web={{ display: 'table', borderCollapse: 'collapse' }}>
|
||
<thead>
|
||
<tr>
|
||
<Th label={t('Include')} />
|
||
<Th>{t('Category')}</Th>
|
||
<Th>{t('Replaces (typical)')}</Th>
|
||
<Th>{t('Hanzo services')}</Th>
|
||
<Th>{t('Coverage')}</Th>
|
||
<Th>{t('Evidence')}</Th>
|
||
<Th right>{t('$k / yr')}</Th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{ROWS.map((r, i) => {
|
||
const off = !on[i]
|
||
const last = i === ROWS.length - 1 && !aiOpen
|
||
return (
|
||
<tr key={r.name}>
|
||
<Td off={off} last={last}>
|
||
<input type="checkbox" checked={on[i]} aria-label={t('Include {name}', { name: t(r.name) })} onChange={(e) => toggleRow(i, e.target.checked)} />
|
||
</Td>
|
||
<Td off={off} last={last}>
|
||
{t(r.name)}
|
||
{i === AI_ROW && (
|
||
<>
|
||
{' '}
|
||
<Text
|
||
render={<button type="button" aria-expanded={aiOpen} onClick={() => setAiOpen((o) => !o)} />}
|
||
py={0}
|
||
px="0.2rem"
|
||
bg="transparent"
|
||
borderWidth={0}
|
||
fontSize="0.68rem" lineHeight={lh(0.68)}
|
||
letterSpacing={0.55}
|
||
textTransform="uppercase"
|
||
textDecorationLine="underline"
|
||
color={SAVE}
|
||
cursor="pointer"
|
||
>
|
||
{aiOpen ? t('hide') : t('sub-calculator')}
|
||
</Text>
|
||
</>
|
||
)}
|
||
</Td>
|
||
<Td off={off} last={last}>
|
||
{t(r.replaces)}
|
||
</Td>
|
||
<Td off={off} last={last} mono>
|
||
{r.hanzo}
|
||
</Td>
|
||
<Td off={off} last={last}>
|
||
<Chip ink={COV_INK[cov[i]]} title={t('Click to regrade coverage')} onPress={() => cycleCov(i)}>
|
||
{t(COV_LABEL[cov[i]])}
|
||
</Chip>
|
||
</Td>
|
||
<Td off={off} last={last}>
|
||
<Chip ink={EV_INK[r.ev]}>{t(EV_LABEL[r.ev])}</Chip>
|
||
</Td>
|
||
<Td off={off} last={last} right>
|
||
<Field
|
||
id={`ts-price-${i}`}
|
||
label={t('Annual estimate for {name} in thousands', { name: t(r.name) })}
|
||
min={0}
|
||
value={Math.round(value[i] / 1000)}
|
||
narrow
|
||
onChange={(n) => setPrice((p) => ({ ...p, [i]: n }))}
|
||
/>
|
||
{WEIGHT[cov[i]] < 1 && (
|
||
<Text ml="0.3rem" fontFamily="$mono" fontSize="0.72rem" lineHeight={lh(0.72)} color={WARN}>
|
||
×{WEIGHT[cov[i]]}
|
||
</Text>
|
||
)}
|
||
</Td>
|
||
</tr>
|
||
)
|
||
})}
|
||
{aiOpen && (
|
||
<tr>
|
||
<Td colSpan={7} last wrap ground>
|
||
<Text render="h3" display="block" mb="0.4rem" fontSize="0.95rem" lineHeight={lh(0.95)} fontWeight="600" color="$ink">
|
||
{t('AI sub-calculator')}
|
||
</Text>
|
||
<Note>
|
||
{t('Hanzo’s current deployment reports reductions approaching 90% for selected workloads routed to lower-cost or self-hosted models. Enter your own eligible workload percentage rather than applying 90% to the entire AI bill. Hanzo routes {models}, but model count is not the economic claim — the control plane is.', {
|
||
models: <Models />,
|
||
})}
|
||
</Note>
|
||
<Controls my={11}>
|
||
{([
|
||
['spend', t('AI spend / month ($k)'), 0, 500, 1],
|
||
['frontier', t('Must stay frontier (%)'), 0, 100, 5],
|
||
['routable', t('Routable to cheaper models (%)'), 0, 100, 5],
|
||
['selfHost', t('Of routed, self-hostable (%)'), 0, 100, 5],
|
||
['ensoSave', t('Enso routing saving (%)'), 0, 90, 5],
|
||
['zenCost', t('Zen cost vs frontier (%)'), 1, 100, 1],
|
||
['gpuUtil', t('GPU utilization (%)'), 10, 100, 5],
|
||
['batch', t('Batch vs interactive mix (%)'), 0, 100, 5],
|
||
['overhead', t('Eval / fallback overhead (%)'), 0, 50, 5],
|
||
] as const).map(([key, label, min, max, step]) => (
|
||
<Range
|
||
key={key}
|
||
id={`ts-ai-${key}`}
|
||
label={label}
|
||
shows={ai[key]}
|
||
min={min}
|
||
max={max}
|
||
step={step}
|
||
value={ai[key]}
|
||
onChange={(n) => setAi((a) => ({ ...a, [key]: n }))}
|
||
/>
|
||
))}
|
||
</Controls>
|
||
<Note>
|
||
{t('Computed eligible saving {saving} / yr against an analyst default of {analyst}. Frontier pass-through is never counted as a saving.', {
|
||
saving: <B mono>{money(aiSaving)}</B>,
|
||
analyst: <B mono>{money(ROWS[AI_ROW].k * 1000)}</B>,
|
||
})}{' '}
|
||
<Toggle pressed={aiOn} onPress={() => setAiOn((v) => !v)}>
|
||
{aiOn ? t('using computed figure') : t('use computed figure')}
|
||
</Toggle>
|
||
</Note>
|
||
</Td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
<tfoot>
|
||
<tr>
|
||
<Td colSpan={6} foot>
|
||
{t('Weighted total — replaced software')}
|
||
</Td>
|
||
<Td right foot mono>
|
||
{money(software)}
|
||
</Td>
|
||
</tr>
|
||
</tfoot>
|
||
</Text>
|
||
</View>
|
||
</YStack>
|
||
|
||
<YStack render="section">
|
||
<H2>{t('Infrastructure delta')}</H2>
|
||
<Note>
|
||
{t('The {pct}% delta above is a simplified default and a {assumption}. The egress anchor is real — DigitalOcean at $0.01/GiB against an AWS example at $0.09/GB — but it supports an egress comparison; it does not prove {pct}% for every workload. A full model would price provider, compute, GPU, database, storage, egress, observability, support, existing commitments, target Hanzo cost and migration overlap separately.', {
|
||
pct: infraPct,
|
||
assumption: <B>{t('user-controlled assumption, not a universal benchmark')}</B>,
|
||
})}
|
||
</Note>
|
||
</YStack>
|
||
|
||
<YStack render="section">
|
||
<H2>{t('Glue engineering')}</H2>
|
||
<Note>
|
||
{t('The seams a vendor stack requires someone to own: {seams}. The default of 1.5 FTE at ${fte}k fully loaded is conservative for a 30+ vendor estate, and 0.5 FTE of platform operations is subtracted on the other side.', {
|
||
seams: SEAMS.map((s) => t(s)).join(', '),
|
||
fte: (FTE / 1000).toFixed(0),
|
||
})}
|
||
</Note>
|
||
<Label>{t('Do not say Hanzo eliminates engineering. Hanzo redirects it from vendor seams to product.')}</Label>
|
||
</YStack>
|
||
|
||
<YStack render="section">
|
||
<H2>{t('The bill is the cheap half')}</H2>
|
||
<Note>
|
||
{t('Everything above prices what you stop {paying}. It ignores what you would have to {build} to own the whole thing yourself. Public anchors: Auth0 acquired for ~$6.5B · Datadog ~$1.55B of 2025 R&D · Zapier 800+ people and 9,000+ integrations. These are not additive valuations; they show each category is a substantial engineering domain. A replacement-cost study of the full repository estate is {analysis} — no number will appear here until that report lands.', {
|
||
paying: <B>{t('paying')}</B>,
|
||
build: <B>{t('build')}</B>,
|
||
analysis: <B>{t('under analysis')}</B>,
|
||
})}
|
||
</Note>
|
||
</YStack>
|
||
|
||
<YStack render="section">
|
||
<H2>{t('Exclusions — what this model never counts')}</H2>
|
||
<Note>
|
||
{t('Card interchange and network fees (the payment row counts PSP markup only) · frontier model pass-through, unless the workload is routed, distilled or self-hosted · deep vertical software (clinical, CAD, EDA, airline operations, industrial control, scientific instruments, jurisdiction-specific record systems) · migration coexistence, where both stacks run at once · retained vendors, since every row is independently deselectable.')}
|
||
</Note>
|
||
</YStack>
|
||
|
||
<YStack render="section">
|
||
<H2>{t('Market context')}</H2>
|
||
<Note>
|
||
{t('Okta 2026: 98 applications on average, 259 at companies with 2,000+ employees, 72 below that. Zylo 2026 SaaS Management Index: a median of $9,455 of SaaS spend per employee, with 36% of licenses unused. The ${software}k software default at the reference deployment works out to $6,130 per employee — below Zylo’s enterprise-skewed median, so the model is conservative relative to the benchmark. None of this implies all SaaS spend is recoverable.', {
|
||
software: Math.round(613),
|
||
})}
|
||
</Note>
|
||
</YStack>
|
||
|
||
<YStack render="section">
|
||
<H2>{t('Second-order benefits — real, unpriced')}</H2>
|
||
<Note>{SECOND_ORDER.map((s) => t(s)).join(' · ')}.</Note>
|
||
<Label>{t('Material, but outside the headline until a defensible measurement exists.')}</Label>
|
||
</YStack>
|
||
|
||
<Card render="section" accent>
|
||
<H2>{t('Replace the stack, not the capability.')}</H2>
|
||
<Note>
|
||
{t('100+ capabilities · {models} · one identity · one API · one platform · self-host or managed.', { models: <Models /> })}
|
||
</Note>
|
||
<Lead>{t('Select your current stack and price the assembly tax.')}</Lead>
|
||
</Card>
|
||
</YStack>
|
||
</YStack>
|
||
)
|
||
}
|
||
|
||
/** The page's one rhythm: a line is half again its size. */
|
||
const lh = (rem: number) => `${+(rem * 1.5).toFixed(4)}rem` as const
|
||
|
||
/* ── the page's parts ──────────────────────────────────────────────────────
|
||
The model reads in the site's own tokens: a saving in the success hue, a
|
||
cost in the error hue, a caveat in the secondary ink — so it follows the
|
||
theme the reader picked, with no palette of its own. */
|
||
|
||
const SAVE = '$good'
|
||
const COST = '$bad'
|
||
const WARN = 'var(--text-secondary)'
|
||
const DIM = 'var(--muted-foreground)'
|
||
const GREY = 'var(--text-tertiary)'
|
||
/** The tint a highlighted tier and a pressed vendor sit on. */
|
||
const SOFT = '$good/12'
|
||
|
||
const COV_INK: Record<Cov, ChipInk> = { full: SAVE, partial: WARN, experimental: COST, roadmap: GREY }
|
||
const EV_INK: Record<Ev, ChipInk> = { verified: SAVE, gap: COST, internal: GREY, repo: GREY, attested: WARN }
|
||
|
||
type ChipInk = typeof SAVE | typeof COST | typeof WARN | typeof GREY
|
||
|
||
function H2({ children }: { children: ReactNode }) {
|
||
return (
|
||
<Text render="h2" display="block" mb="0.5rem" fontSize="1.05rem" lineHeight={lh(1.05)} letterSpacing={-0.17} fontWeight="600" color="var(--foreground)">
|
||
{children}
|
||
</Text>
|
||
)
|
||
}
|
||
|
||
function Lead({ children }: { children: ReactNode }) {
|
||
return (
|
||
<Text render="p" display="block" mt="0.4rem" fontSize="1.15rem" lineHeight={lh(1.15)} color={SAVE}>
|
||
{children}
|
||
</Text>
|
||
)
|
||
}
|
||
|
||
function Note({ children }: { children: ReactNode }) {
|
||
return (
|
||
<Text render="p" display="block" maxW="min(76ch, 100%)" fontSize="0.88rem" lineHeight={lh(0.88)} color={DIM}>
|
||
{children}
|
||
</Text>
|
||
)
|
||
}
|
||
|
||
/** The emphasis inside a note: full ink, and the mono face for a figure. */
|
||
function B({ mono, children }: { mono?: boolean; children: ReactNode }) {
|
||
return (
|
||
<Text render="b" lineHeight="unset" color="$ink" fontFamily={mono ? '$mono' : undefined} $platform-web={mono ? { fontVariantNumeric: 'tabular-nums' } : undefined}>
|
||
{children}
|
||
</Text>
|
||
)
|
||
}
|
||
|
||
function Code({ children }: { children: ReactNode }) {
|
||
return (
|
||
<Text render="code" lineHeight="unset" fontFamily="$mono" fontSize="0.66rem" color="$ink">
|
||
{children}
|
||
</Text>
|
||
)
|
||
}
|
||
|
||
function Label({ children }: { children: ReactNode }) {
|
||
return (
|
||
<Text render="p" display="block" mt="0.5rem" fontSize="0.72rem" lineHeight={lh(0.72)} letterSpacing={1.04} textTransform="uppercase" color={WARN}>
|
||
{children}
|
||
</Text>
|
||
)
|
||
}
|
||
|
||
/** A panel; `accent` rules its left edge in the saving hue. */
|
||
function Card({ render, accent, children }: { render?: 'section'; accent?: boolean; children: ReactNode }) {
|
||
return (
|
||
<YStack
|
||
render={render}
|
||
bg="$panel"
|
||
borderWidth={1}
|
||
borderColor="$edge"
|
||
borderLeftWidth={accent ? 3 : 1}
|
||
borderLeftColor={accent ? SAVE : '$edge'}
|
||
py="0.8rem"
|
||
px="1rem"
|
||
gap="0.4rem"
|
||
>
|
||
{children}
|
||
</YStack>
|
||
)
|
||
}
|
||
|
||
/** The grid the controls sit in: as many 14rem columns as fit. */
|
||
function Controls({ my, children }: { my?: number; children: ReactNode }) {
|
||
return (
|
||
<View display="grid" gridTemplateColumns="repeat(auto-fit, minmax(14rem, 1fr))" gap="0.9rem" my={my}>
|
||
{children}
|
||
</View>
|
||
)
|
||
}
|
||
|
||
/** A control's caption, in small caps; `shows` is its value at the right. */
|
||
function Caption({ id, shows, children }: { id: string; shows?: ReactNode; children: ReactNode }) {
|
||
return (
|
||
<Text
|
||
render={<label htmlFor={id} />}
|
||
display="flex"
|
||
justify="space-between"
|
||
gap="0.5rem"
|
||
fontSize="0.7rem" lineHeight={lh(0.7)}
|
||
letterSpacing={1.01}
|
||
textTransform="uppercase"
|
||
color={DIM}
|
||
>
|
||
<Text lineHeight="unset" color="inherit">{children}</Text>
|
||
{shows !== undefined ? (
|
||
<Text render="output" lineHeight="unset" fontFamily="$mono" textTransform="none" letterSpacing={0} color="$ink">
|
||
{shows}
|
||
</Text>
|
||
) : null}
|
||
</Text>
|
||
)
|
||
}
|
||
|
||
/** A slider in a card, its caption above it. */
|
||
function Range({
|
||
id,
|
||
label,
|
||
shows,
|
||
min,
|
||
max,
|
||
step,
|
||
value,
|
||
onChange,
|
||
}: {
|
||
id: string
|
||
label: string
|
||
shows: ReactNode
|
||
min: number
|
||
max: number
|
||
step: number
|
||
value: number
|
||
onChange: (n: number) => void
|
||
}) {
|
||
return (
|
||
<Card>
|
||
<Caption id={id} shows={shows}>
|
||
{label}
|
||
</Caption>
|
||
<View
|
||
render={<input id={id} type="range" min={min} max={max} step={step} value={value} onChange={(e) => onChange(+e.target.value)} />}
|
||
width="100%"
|
||
$platform-web={{ accentColor: 'var(--state-success)' }}
|
||
/>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
/** A number field; `narrow` for a price cell in the table. */
|
||
function Field({
|
||
id,
|
||
label,
|
||
min,
|
||
step,
|
||
value,
|
||
placeholder,
|
||
narrow,
|
||
onChange,
|
||
}: {
|
||
id: string
|
||
label?: string
|
||
min: number
|
||
step?: number
|
||
value: number | ''
|
||
placeholder?: string
|
||
narrow?: boolean
|
||
onChange: (n: number) => void
|
||
}) {
|
||
return (
|
||
<Text
|
||
render={
|
||
<input
|
||
id={id}
|
||
type="number"
|
||
aria-label={label}
|
||
min={min}
|
||
step={step}
|
||
value={value}
|
||
placeholder={placeholder}
|
||
onChange={(e) => onChange(Math.max(Number(e.target.value) || 0, 0))}
|
||
/>
|
||
}
|
||
display={narrow ? undefined : 'block'}
|
||
width={narrow ? '5.5rem' : '100%'}
|
||
py="0.3rem"
|
||
px="0.45rem"
|
||
text={narrow ? 'right' : undefined}
|
||
bg="var(--background)"
|
||
borderWidth={1}
|
||
borderColor="$edge"
|
||
fontFamily="$mono"
|
||
fontSize="0.85rem" lineHeight={lh(0.85)}
|
||
color="$ink"
|
||
/>
|
||
)
|
||
}
|
||
|
||
/** One of the four tiers; `lead` is the headline, on the saving tint. */
|
||
function Tier({ label, note, lead, children }: { label: string; note: string; lead?: boolean; children: ReactNode }) {
|
||
return (
|
||
<YStack bg={lead ? SOFT : '$panel'} borderWidth={1} borderColor={lead ? SAVE : '$edge'} py="1rem" px="1.1rem" gap="0.3rem">
|
||
<Text fontSize="0.7rem" lineHeight={lh(0.7)} letterSpacing={1.01} textTransform="uppercase" color={DIM}>
|
||
{label}
|
||
</Text>
|
||
{children}
|
||
<Text render="small" fontSize="0.76rem" lineHeight={lh(0.76)} color={DIM}>
|
||
{note}
|
||
</Text>
|
||
</YStack>
|
||
)
|
||
}
|
||
|
||
/** A tier's figure: the saving hue, in lining mono figures, tight. */
|
||
function Big({ children }: { children: ReactNode }) {
|
||
return (
|
||
<Text
|
||
display="block"
|
||
fontFamily="$mono"
|
||
fontSize="1.7rem"
|
||
lineHeight="1.785rem"
|
||
letterSpacing={-1.06}
|
||
color={SAVE}
|
||
$md={{ fontSize: '2.4rem', lineHeight: '2.52rem' }}
|
||
$platform-web={{ fontVariantNumeric: 'tabular-nums' }}
|
||
>
|
||
{children}
|
||
</Text>
|
||
)
|
||
}
|
||
|
||
/** One part of the run-rate, ruled across its top in the saving hue — or the cost hue for what Hanzo costs. */
|
||
function Part({ label, cost, children }: { label: string; cost?: boolean; children: ReactNode }) {
|
||
return (
|
||
<YStack bg="$panel" borderWidth={1} borderColor="$edge" borderTopWidth={3} borderTopColor={cost ? COST : SAVE} py="0.7rem" px="0.9rem">
|
||
<Text render="small" display="block" mb="0.2rem" fontSize="0.72rem" lineHeight={lh(0.72)} color={DIM}>
|
||
{label}
|
||
</Text>
|
||
<Text fontFamily="$mono" fontSize="1.15rem" lineHeight={lh(1.15)} color="$ink" $platform-web={{ fontVariantNumeric: 'tabular-nums' }}>
|
||
{children}
|
||
</Text>
|
||
</YStack>
|
||
)
|
||
}
|
||
|
||
/** A vendor, or a small action set the same way; pressed reads in the saving hue on its tint. */
|
||
function Toggle({ pressed, onPress, children }: { pressed?: boolean; onPress: () => void; children: ReactNode }) {
|
||
return (
|
||
<Text
|
||
render={<button type="button" aria-pressed={pressed} onClick={onPress} />}
|
||
py="0.28rem"
|
||
px="0.55rem"
|
||
borderWidth={1}
|
||
borderColor={pressed ? SAVE : '$edge'}
|
||
bg={pressed ? SOFT : '$panel'}
|
||
fontSize="0.78rem" lineHeight={lh(0.78)}
|
||
color={pressed ? SAVE : '$ink'}
|
||
cursor="pointer"
|
||
>
|
||
{children}
|
||
</Text>
|
||
)
|
||
}
|
||
|
||
/** A grade: a word in an outline, in its grade's ink; a button where the grade can be changed. */
|
||
function Chip({ ink, title, onPress, children }: { ink: ChipInk; title?: string; onPress?: () => void; children: ReactNode }) {
|
||
return (
|
||
<Text
|
||
render={onPress ? <button type="button" title={title} onClick={onPress} /> : 'span'}
|
||
py="0.12rem"
|
||
px="0.45rem"
|
||
borderWidth={1}
|
||
borderColor={ink}
|
||
bg="transparent"
|
||
fontFamily="$body"
|
||
fontSize="0.66rem" lineHeight={lh(0.66)}
|
||
letterSpacing={0.53}
|
||
textTransform="uppercase"
|
||
whiteSpace="nowrap"
|
||
color={ink}
|
||
cursor={onPress ? 'pointer' : undefined}
|
||
>
|
||
{children}
|
||
</Text>
|
||
)
|
||
}
|
||
|
||
/** A table cell, stated a table cell: a gui Text is an inline box until told. */
|
||
const CELL = { '$platform-web': { display: 'table-cell' } } as const
|
||
|
||
/** A column head: small caps, held at the top while the table scrolls. */
|
||
function Th({ right, label, children }: { right?: boolean; label?: string; children?: ReactNode }) {
|
||
return (
|
||
<Text
|
||
render={<th aria-label={label} />}
|
||
{...CELL}
|
||
position="sticky"
|
||
t={0}
|
||
z="var(--z-raised)"
|
||
py="0.45rem"
|
||
px="0.7rem"
|
||
text={right ? 'right' : 'left'}
|
||
whiteSpace="nowrap"
|
||
borderBottomWidth={1}
|
||
borderColor="$edge"
|
||
bg="$panel"
|
||
fontSize="0.65rem" lineHeight={lh(0.65)}
|
||
letterSpacing={0.94}
|
||
textTransform="uppercase"
|
||
color={DIM}
|
||
>
|
||
{children}
|
||
</Text>
|
||
)
|
||
}
|
||
|
||
/** A body cell. `off` dims a row the reader left out; `foot` is the total under the table. */
|
||
function Td({
|
||
right,
|
||
mono,
|
||
off,
|
||
last,
|
||
wrap,
|
||
ground,
|
||
foot,
|
||
colSpan,
|
||
children,
|
||
}: {
|
||
right?: boolean
|
||
mono?: boolean
|
||
off?: boolean
|
||
last?: boolean
|
||
wrap?: boolean
|
||
ground?: boolean
|
||
foot?: boolean
|
||
colSpan?: number
|
||
children?: ReactNode
|
||
}) {
|
||
return (
|
||
<Text
|
||
render={<td colSpan={colSpan} />}
|
||
{...CELL}
|
||
py={ground ? '1rem' : '0.45rem'}
|
||
px={ground ? '1rem' : '0.7rem'}
|
||
text={right ? 'right' : 'left'}
|
||
whiteSpace={wrap ? 'normal' : 'nowrap'}
|
||
borderBottomWidth={last || foot ? 0 : 1}
|
||
borderTopWidth={foot ? 2 : 0}
|
||
borderColor={foot ? '$ink' : '$edge'}
|
||
bg={ground ? 'var(--background)' : undefined}
|
||
opacity={off ? 0.42 : 1}
|
||
fontFamily={mono ? '$mono' : undefined}
|
||
fontSize={mono && !foot ? '0.76rem' : undefined}
|
||
lineHeight={mono && !foot ? lh(0.76) : lh(0.85)}
|
||
fontWeight={foot ? '700' : undefined}
|
||
color={mono && !foot ? DIM : '$ink'}
|
||
$platform-web={mono ? { display: 'table-cell', fontVariantNumeric: 'tabular-nums' } : CELL['$platform-web']}
|
||
>
|
||
{children}
|
||
</Text>
|
||
)
|
||
}
|