mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
The shared chunks grew under it and took the page to 408 KB against perf.budget.json's 400; the calculator stands below the fold, as on /enterprise, so its script loads after the page's own.
989 lines
44 KiB
TypeScript
989 lines
44 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'
|
||
|
||
// 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: 'full',
|
||
partial: 'partial 50%',
|
||
experimental: 'experimental 25%',
|
||
roadmap: 'roadmap 0%',
|
||
}
|
||
const EV_LABEL: Record<Ev, string> = {
|
||
verified: 'verified',
|
||
gap: 'verified gap',
|
||
internal: 'internal production',
|
||
attested: 'management-attested',
|
||
repo: '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: 'SSO & workforce identity', replaces: 'Okta / Auth0', hanzo: 'iam · zero-trust · authz', k: 10, basis: 's', cov: 'full', ev: 'internal' },
|
||
{ name: 'Customer identity (product)', replaces: 'Auth0 B2C', hanzo: 'iam (multi-tenant, per-org CEK)', k: 14, basis: 'f', cov: 'full', ev: 'internal' },
|
||
{ name: 'Secrets & KMS', replaces: 'Vault Cloud', hanzo: 'kms', k: 11, basis: 'e', cov: 'full', ev: 'internal' },
|
||
{ name: 'Observability', replaces: 'Datadog / New Relic', hanzo: 'o11y · metrics', k: 54, basis: 'e', cov: 'full', ev: 'internal' },
|
||
{ name: 'Error tracking', replaces: 'Sentry', hanzo: 'o11y (errortracking)', k: 9, basis: 'e', cov: 'full', ev: 'internal' },
|
||
{ name: 'Feature flags', replaces: 'LaunchDarkly', hanzo: 'flags', k: 7, basis: 'e', cov: 'full', ev: 'repo' },
|
||
{ name: 'Product analytics', replaces: 'Amplitude / Mixpanel', hanzo: 'analytics', k: 15, basis: 'f', cov: 'full', ev: 'internal' },
|
||
{ name: 'Event routing / CDP', replaces: 'Segment', hanzo: 'destinations · tracker', k: 22, basis: 'f', cov: 'full', ev: 'repo' },
|
||
{ name: 'Git hosting', replaces: 'GitHub Enterprise', hanzo: 'git (git.hanzo.ai)', k: 8, basis: 'e', cov: 'full', ev: 'internal' },
|
||
{ name: 'CI/CD', replaces: 'CircleCI / Actions', hanzo: 'hanzoai/ci · arc runners', k: 18, basis: 'e', cov: 'full', ev: 'internal' },
|
||
{ name: 'Container registry', replaces: 'ECR / Docker Hub', hanzo: 'registry (oci.hanzo.ai)', k: 4, basis: 'e', cov: 'full', ev: 'internal' },
|
||
{ name: 'PaaS / app hosting', replaces: 'Vercel / Heroku', hanzo: 'paas · deploy · functions', k: 13, basis: 'e', cov: 'full', ev: 'internal' },
|
||
{ name: 'App backend / BaaS', replaces: 'Supabase / Firebase', hanzo: 'base · docdb · orm', k: 15, basis: 'f', cov: 'full', ev: 'internal' },
|
||
{ name: 'Queues & streaming', replaces: 'Confluent / SQS', hanzo: 'pubsub (NATS) · kafka', k: 10, basis: 'f', cov: 'full', ev: 'repo' },
|
||
{ name: 'AI gateway & routing', replaces: 'Portkey / OpenRouter', hanzo: 'ai · zen · gateway', k: 12, basis: 'f', cov: 'full', ev: 'internal' },
|
||
{ name: 'Workflow automation', replaces: 'Zapier', hanzo: 'automations · connectorruntime · cron', k: 12, basis: 's', cov: 'full', ev: 'repo' },
|
||
{ name: 'Billing & metering', replaces: 'Chargebee / Metronome', hanzo: 'billing · usage · entitlements · pricing', k: 18, basis: 'f', cov: 'full', ev: 'internal' },
|
||
{ name: 'Commerce', replaces: 'Shopify Plus', hanzo: 'commerce · marketplace', k: 24, basis: 'f', cov: 'full', ev: 'repo' },
|
||
{ name: 'E-signature', replaces: 'DocuSign', hanzo: 'sign', k: 2, basis: 's', cov: 'full', ev: 'repo' },
|
||
{ name: 'Cap table', replaces: 'Carta', hanzo: 'captable', k: 5, basis: 'f', cov: 'full', ev: 'internal' },
|
||
{ name: 'Data rooms', replaces: 'DocSend / iDeals', hanzo: 'dataroom', k: 6, basis: 'f', cov: 'full', ev: 'repo' },
|
||
{ name: 'Referrals & affiliates', replaces: 'PartnerStack', hanzo: 'affiliates · referrals · link', k: 9, basis: 'f', cov: 'full', ev: 'repo' },
|
||
{ name: 'Experiments & evals', replaces: 'Statsig / Optimizely', hanzo: 'experiments · evals', k: 12, basis: 'f', cov: 'full', ev: 'repo' },
|
||
{ name: 'Zero-trust access', replaces: 'Tailscale / CF Access', hanzo: 'zero-trust · ingress', k: 7, basis: 's', cov: 'full', ev: 'internal' },
|
||
{ name: 'Search', replaces: 'Algolia / Elastic', hanzo: 'index · websearch', k: 12, basis: 'f', cov: 'full', ev: 'repo' },
|
||
{ name: 'Vector store', replaces: 'Pinecone', hanzo: 'knowledge', k: 8, basis: 'f', cov: 'partial', ev: 'gap' },
|
||
{ name: 'Compliance automation', replaces: 'Vanta / Drata', hanzo: 'compliance · audit · sbom · security', k: 18, basis: 'f', cov: 'full', ev: 'repo' },
|
||
{ name: 'CRM', replaces: 'Salesforce / HubSpot', hanzo: 'crm · company', k: 27, basis: 's', cov: 'full', ev: 'internal' },
|
||
{ name: 'Marketing automation', replaces: 'HubSpot Marketing', hanzo: 'marketing · campaign', k: 10, basis: 'f', cov: 'full', ev: 'repo' },
|
||
{ name: 'Projects & tasks', replaces: 'Linear / Asana', hanzo: 'projects · tasks', k: 14, basis: 's', cov: 'full', ev: 'internal' },
|
||
{ name: 'Knowledge & docs', replaces: 'Notion / Confluence', hanzo: 'knowledge · guide', k: 12, basis: 's', cov: 'full', ev: 'internal' },
|
||
{ name: 'Support & messaging', replaces: 'Intercom / Zendesk', hanzo: 'help · ask · channels · bots', k: 11, basis: 's', cov: 'full', ev: 'internal' },
|
||
{ name: 'Notifications & email', replaces: 'SendGrid / Customer.io', hanzo: 'notify', k: 7, basis: 'f', cov: 'full', ev: 'internal' },
|
||
{ name: 'Incident & status', replaces: 'PagerDuty / Statuspage', hanzo: 'o11y (incident · status)', k: 11, basis: 'e', cov: 'full', ev: 'internal' },
|
||
{ name: 'Finance ops & ledger', replaces: 'NetSuite add-ons', hanzo: 'books · treasury · ledger-fi', k: 6, basis: 'f', cov: 'full', ev: 'internal' },
|
||
{ name: 'Design & creative', replaces: 'Figma / Canva', hanzo: 'studio (studio.hanzo.ai)', k: 16, basis: 's', cov: 'full', ev: 'repo' },
|
||
{ name: 'Video, calls & calendar', replaces: 'Zoom / Calendly', hanzo: 'team (calls · calendar)', k: 18, basis: 's', cov: 'full', ev: 'internal' },
|
||
{ name: 'Office suite & drive', replaces: 'Google Workspace', hanzo: 'office · drive', k: 14, basis: 's', cov: 'full', ev: 'internal' },
|
||
{ name: 'HR & payroll', replaces: 'Gusto / Rippling', hanzo: 'hr · payroll', k: 48, basis: 's', cov: 'full', ev: 'internal' },
|
||
{ name: 'Payment orchestration', replaces: 'PSP markup over interchange+', hanzo: 'psp · x402 · wallets', k: 12, basis: 'f', cov: 'full', ev: 'repo' },
|
||
{ name: 'ERP / CMS / business apps', replaces: 'NetSuite / WordPress', hanzo: 'erp · base', k: 20, basis: 'f', cov: 'full', ev: 'internal' },
|
||
{ name: '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 = [
|
||
'auth bridges', 'webhooks', 'data sync', 'billing reconciliation', 'entitlements', 'audit exports',
|
||
'pipelines', 'vendor o11y', 'incident routing', 'schema transforms', 'secret rotation', 'API migrations',
|
||
'identity lifecycle', 'model fallbacks', 'security reviews',
|
||
]
|
||
|
||
const SECOND_ORDER = [
|
||
'SSO price premiums', 'per-vendor security reviews', 'DPAs', 'procurement cycles', 'duplicate observability',
|
||
'license reclamation', 'credential sprawl', 'integration attack surface', 'onboarding speed', 'unified policy',
|
||
'common metering', 'model portability', 'lock-in exposure', 'air-gapped deployment', 'incident response speed',
|
||
'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 [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' }}>
|
||
<YStack width="100%" maxW="72rem" mx="auto" pt="2.5rem" px="1.25rem" pb="5rem" gap="2rem">
|
||
<YStack render="header">
|
||
<Text render="h1" display="block" fontSize="2rem" lineHeight="2.1rem" letterSpacing="$12" fontWeight="600" color="var(--foreground)" $md={{ fontSize: '2.75rem', lineHeight: '2.8875rem' }}>
|
||
Hanzo True Savings
|
||
</Text>
|
||
<Lead>Price the assembly tax.</Lead>
|
||
<Text render="p" display="block" mt="0.6rem" fontSize="0.95rem" lineHeight={lh(0.95)} fontWeight="500" color="$ink">
|
||
100+ capabilities · <Models /> · one identity plane · one API · one operating platform.
|
||
</Text>
|
||
<Text render="p" display="block" mt="0.5rem" maxW="min(62ch, 100%)" fontSize="0.95rem" lineHeight={lh(0.95)} color={DIM}>
|
||
This is not a TAM calculator. It models operating expenses a company can eliminate, reduce,
|
||
or reclaim. Every assumption is visible and editable.
|
||
</Text>
|
||
</YStack>
|
||
|
||
<Card render="section" accent>
|
||
<H2>Reference deployment — Hanzo runs on Hanzo</H2>
|
||
<Note>
|
||
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 ·{' '}
|
||
<B>107 vendor and managed-service dependencies consolidated into one plane</B> · ~90% lower
|
||
compute and eligible-inference costs in selected workloads.
|
||
</Note>
|
||
<Label>Founder-attested.</Label>
|
||
</Card>
|
||
|
||
<YStack render="section" id="tokens" gap="1rem">
|
||
<H2>Your AI bill, three ways</H2>
|
||
<Note>
|
||
The tokens you use a month at the providers' list prices, on Hanzo Cloud, and on GPUs of your own. Every rate
|
||
is read from our price list when the page opens; the volumes are yours.
|
||
</Note>
|
||
<Savings />
|
||
</YStack>
|
||
|
||
<YStack render="section">
|
||
<H2>Your deployment</H2>
|
||
<Controls>
|
||
<Range id="ts-e" label="Employees" shows={E} min={10} max={2000} step={10} value={E} onChange={setE} />
|
||
<Range id="ts-d" label="Engineers" shows={`${Dp}% (${D})`} min={10} max={60} step={5} value={Dp} onChange={setDp} />
|
||
<Range id="ts-c" label="Cloud bill / month" shows={`$${cloud}k`} min={0} max={250} step={5} value={cloud} onChange={setCloud} />
|
||
<Range id="ts-g" label="Integration-glue FTEs" shows={glueFte} min={0} max={20} step={0.5} value={glueFte} onChange={setGlueFte} />
|
||
<Range id="ts-o" label="Platform-ops FTEs (running Hanzo)" shows={opsFte} min={0} max={6} step={0.5} value={opsFte} onChange={setOpsFte} />
|
||
<Range id="ts-i" label="Infrastructure delta" shows={`${infraPct}%`} min={0} max={90} step={5} value={infraPct} onChange={setInfraPct} />
|
||
</Controls>
|
||
</YStack>
|
||
|
||
<YStack render="section">
|
||
<H2>Deductions — not included until entered</H2>
|
||
<Controls>
|
||
<Card>
|
||
<Caption id="ts-r">Retained vendor spending ($/yr)</Caption>
|
||
{num('ts-r', retained, setRetained)}
|
||
</Card>
|
||
<Card>
|
||
<Caption id="ts-f">Hanzo managed / support fee ($/yr)</Caption>
|
||
{num('ts-f', fee, setFee)}
|
||
</Card>
|
||
<Card>
|
||
<Caption id="ts-m">One-time migration cost ($)</Caption>
|
||
{num('ts-m', migration, setMigration)}
|
||
</Card>
|
||
<Range id="ts-y" label="Amortized over" shows={`${migYears} yr`} min={1} max={5} step={1} value={migYears} onChange={setMigYears} />
|
||
</Controls>
|
||
</YStack>
|
||
|
||
<YStack render="section">
|
||
<H2>Results</H2>
|
||
<View display="grid" gridTemplateColumns="repeat(auto-fit, minmax(15rem, 1fr))" gap="0.9rem">
|
||
<Tier label="1 · Gross addressable savings" note="Software + infrastructure + glue, before any deduction.">
|
||
<Big>{money(gross)}</Big>
|
||
</Tier>
|
||
<Tier lead label="2 · Annual run-rate savings" note="Gross − Hanzo operating labor. This is the headline.">
|
||
<Big>{money(runRate)}</Big>
|
||
</Tier>
|
||
<Tier label="3 · Fully adjusted annual savings" note="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}>
|
||
Customer input required
|
||
</Text>
|
||
)}
|
||
</Tier>
|
||
<Tier label="4 · Three-year economic value" note="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="Software replaced (selected rows)">{money(software)}</Part>
|
||
<Part label="Infrastructure delta">{money(infra)}</Part>
|
||
<Part label="Glue engineering reclaimed">{money(glue)}</Part>
|
||
<Part label="Cost of operating Hanzo" cost>
|
||
−{money(ops)}
|
||
</Part>
|
||
<Part label="Per employee / yr (run-rate)">{dollars(runRate / Math.max(E, 1))}</Part>
|
||
<Part label="≈ senior engineers funded">{(runRate / FTE).toFixed(1)}</Part>
|
||
<Part label="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}>
|
||
Do not present {money(runRate)} as fully net until the final three deductions are entered.
|
||
</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}>
|
||
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>Click everything you pay for</H2>
|
||
<Note>
|
||
Every vendor maps to a line in the table. Tap the ones on your bill — the totals and the{' '}
|
||
<B>vendors → 1</B> counter follow. <Toggle onPress={() => setAllVendors(true)}>select all</Toggle>{' '}
|
||
<Toggle onPress={() => setAllVendors(false)}>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>Category by category</H2>
|
||
<Note>
|
||
Two axes, kept separate. <B>Coverage</B> carries weight and is clickable — full 100%,
|
||
partial 50%, experimental 25%, roadmap 0%. <B>Evidence</B> carries no weight. A service can
|
||
have full coverage while still management-attested. Separating the axes prevents an
|
||
attestation from being presented as verification.
|
||
</Note>
|
||
<Note>
|
||
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}>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="Include" />
|
||
<Th>Category</Th>
|
||
<Th>Replaces (typical)</Th>
|
||
<Th>Hanzo services</Th>
|
||
<Th>Coverage</Th>
|
||
<Th>Evidence</Th>
|
||
<Th right>$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={`Include ${r.name}`} onChange={(e) => toggleRow(i, e.target.checked)} />
|
||
</Td>
|
||
<Td off={off} last={last}>
|
||
{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 ? 'hide' : 'sub-calculator'}
|
||
</Text>
|
||
</>
|
||
)}
|
||
</Td>
|
||
<Td off={off} last={last}>
|
||
{r.replaces}
|
||
</Td>
|
||
<Td off={off} last={last} mono>
|
||
{r.hanzo}
|
||
</Td>
|
||
<Td off={off} last={last}>
|
||
<Chip ink={COV_INK[cov[i]]} title="Click to regrade coverage" onPress={() => cycleCov(i)}>
|
||
{COV_LABEL[cov[i]]}
|
||
</Chip>
|
||
</Td>
|
||
<Td off={off} last={last}>
|
||
<Chip ink={EV_INK[r.ev]}>{EV_LABEL[r.ev]}</Chip>
|
||
</Td>
|
||
<Td off={off} last={last} right>
|
||
<Field
|
||
id={`ts-price-${i}`}
|
||
label={`Annual estimate for ${r.name} in thousands`}
|
||
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">
|
||
AI sub-calculator
|
||
</Text>
|
||
<Note>
|
||
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.
|
||
</Note>
|
||
<Controls my={11}>
|
||
{([
|
||
['spend', 'AI spend / month ($k)', 0, 500, 1],
|
||
['frontier', 'Must stay frontier (%)', 0, 100, 5],
|
||
['routable', 'Routable to cheaper models (%)', 0, 100, 5],
|
||
['selfHost', 'Of routed, self-hostable (%)', 0, 100, 5],
|
||
['ensoSave', 'Enso routing saving (%)', 0, 90, 5],
|
||
['zenCost', 'Zen cost vs frontier (%)', 1, 100, 1],
|
||
['gpuUtil', 'GPU utilization (%)', 10, 100, 5],
|
||
['batch', 'Batch vs interactive mix (%)', 0, 100, 5],
|
||
['overhead', '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>
|
||
Computed eligible saving <B mono>{money(aiSaving)}</B> / yr against an analyst default of{' '}
|
||
<B mono>{money(ROWS[AI_ROW].k * 1000)}</B>. Frontier pass-through is never counted as a saving.{' '}
|
||
<Toggle pressed={aiOn} onPress={() => setAiOn((v) => !v)}>
|
||
{aiOn ? 'using computed figure' : 'use computed figure'}
|
||
</Toggle>
|
||
</Note>
|
||
</Td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
<tfoot>
|
||
<tr>
|
||
<Td colSpan={6} foot>
|
||
Weighted total — replaced software
|
||
</Td>
|
||
<Td right foot mono>
|
||
{money(software)}
|
||
</Td>
|
||
</tr>
|
||
</tfoot>
|
||
</Text>
|
||
</View>
|
||
</YStack>
|
||
|
||
<YStack render="section">
|
||
<H2>Infrastructure delta</H2>
|
||
<Note>
|
||
The {infraPct}% delta above is a simplified default and a{' '}
|
||
<B>user-controlled assumption, not a universal benchmark</B>. 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 {infraPct}% 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.
|
||
</Note>
|
||
</YStack>
|
||
|
||
<YStack render="section">
|
||
<H2>Glue engineering</H2>
|
||
<Note>
|
||
The seams a vendor stack requires someone to own: {SEAMS.join(', ')}. The default of 1.5 FTE
|
||
at ${(FTE / 1000).toFixed(0)}k fully loaded is conservative for a 30+ vendor estate, and
|
||
0.5 FTE of platform operations is subtracted on the other side.
|
||
</Note>
|
||
<Label>Do not say Hanzo eliminates engineering. Hanzo redirects it from vendor seams to product.</Label>
|
||
</YStack>
|
||
|
||
<YStack render="section">
|
||
<H2>The bill is the cheap half</H2>
|
||
<Note>
|
||
Everything above prices what you stop <B>paying</B>. It ignores what you would have to{' '}
|
||
<B>build</B> 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 <B>under analysis</B> — no number
|
||
will appear here until that report lands.
|
||
</Note>
|
||
</YStack>
|
||
|
||
<YStack render="section">
|
||
<H2>Exclusions — what this model never counts</H2>
|
||
<Note>
|
||
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>Market context</H2>
|
||
<Note>
|
||
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 ${Math.round(613)}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.
|
||
</Note>
|
||
</YStack>
|
||
|
||
<YStack render="section">
|
||
<H2>Second-order benefits — real, unpriced</H2>
|
||
<Note>{SECOND_ORDER.join(' · ')}.</Note>
|
||
<Label>Material, but outside the headline until a defensible measurement exists.</Label>
|
||
</YStack>
|
||
|
||
<Card render="section" accent>
|
||
<H2>Replace the stack, not the capability.</H2>
|
||
<Note>
|
||
100+ capabilities · <Models /> · one identity · one API · one platform · self-host or
|
||
managed.
|
||
</Note>
|
||
<Lead>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>
|
||
)
|
||
}
|