mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
The bar is Models, Dev, Platform, Pricing, Docs, Enterprise, Log in, and Get API key. The homepage is seven sections. Launch pages stay in the tree and drop out of the index through lib/launch.ts. /platform forwards to /cloud. Model and repository floors come from the snapshots the build already keeps. Co-authored-by: z <[email protected]>
468 lines
17 KiB
TypeScript
468 lines
17 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useRef, useState, type PointerEvent } from 'react'
|
||
import dynamic from 'next/dynamic'
|
||
import { Check, Copy as CopyIcon } from 'lucide-react'
|
||
import { useAnalytics } from '@hanzo/event/react'
|
||
import { phrase } from '@hanzo/i18n'
|
||
import { useI18n } from '@hanzo/i18n/react'
|
||
import { useIam } from '@hanzo/iam/react'
|
||
import { Button, Text, View, XStack, YStack, type GuiElement } from '@hanzo/ui'
|
||
import { Action, Display, Lede, TextLink } from '@hanzo/ui/marketing'
|
||
// The Hanzo mark from the shell the bar already draws: ProviderMark carries every lab's mark, a chunk the first screen does not need.
|
||
import { HanzoMark } from '@hanzogui/shell'
|
||
import { Panel } from '@/components/auth/panel'
|
||
import { track } from '@hanzo/app/lib/tags'
|
||
import { SH } from './nav-data'
|
||
import { ROOF } from '@/components/shell/dock'
|
||
import { STRIP } from './beta'
|
||
import { Opening, Win, node, useStill } from './chrome'
|
||
import { Label } from './label'
|
||
|
||
/**
|
||
* The first screen: one line of what Hanzo is worth, the sign-up beside it, and
|
||
* two commands a reader copies whole, with every other coding agent one press
|
||
* away.
|
||
*
|
||
* EVERY COMMAND HERE RUNS TODAY (checked against CLI 8.5.732, the one hanzo.sh
|
||
* installs): hanzo.sh answers with the POSIX installer, and `hanzo dev --claude`
|
||
* runs Claude Code with the Hanzo MCP toolset attached and the session streamed
|
||
* to mission control, on the account Claude Code is signed in to. The agent menu
|
||
* is CopyPrompt, a chunk of its own, so its marks stay off the first screen.
|
||
*
|
||
* A reader already signed in is offered the app instead of the sign-up, and
|
||
* the sign-up is the bare Panel, not the Gate: it asks IAM nothing on arrival
|
||
* and moves no one off the page, and a sign-in starts only from a press. The
|
||
* view is counted here.
|
||
*
|
||
* Events: home_copied { block }.
|
||
*/
|
||
const BLOCKS = [{ key: 'install', label: phrase('Install the Hanzo CLI'), lines: [`curl -fsSL ${SH} | sh`] }] as const
|
||
|
||
/**
|
||
* The CLI's major commands, each with what it does, as the tour types them
|
||
* (`Tour`). Every one is the CLI's own (`hanzo --help`, `hanzo dev --help`,
|
||
* `hanzo up --help`); Claude Code's first, which is what the page shows before
|
||
* anything moves.
|
||
*/
|
||
const TOUR = [
|
||
{ label: phrase('Accelerate Claude Code with Hanzo'), command: 'hanzo dev --claude' },
|
||
{ label: phrase('Accelerate Codex with Hanzo'), command: 'hanzo dev --codex' },
|
||
{ label: phrase('Accelerate Cursor with Hanzo'), command: 'hanzo dev --cursor' },
|
||
{ label: phrase('Accelerate Antigravity with Hanzo'), command: 'hanzo dev --agy' },
|
||
{ label: phrase('Accelerate Gemini CLI with Hanzo'), command: 'hanzo dev --gemini' },
|
||
{ label: phrase('Run any agent on Hanzo’s models'), command: 'hanzo dev --claude --cloud' },
|
||
{ label: phrase('Hand Hanzo Dev a task'), command: 'hanzo "fix the failing test"' },
|
||
{ label: phrase('Run a local copy of Hanzo’s agentic cloud'), command: 'hanzo up' },
|
||
{ label: phrase('Open the fleet console'), command: 'hanzo console' },
|
||
{ label: phrase('See the whole cloud’s health'), command: 'hanzo status' },
|
||
{ label: phrase('See what you have used'), command: 'hanzo usage' },
|
||
{ label: phrase('Check your balance'), command: 'hanzo billing balance' },
|
||
] as const
|
||
|
||
/** Milliseconds a character takes to type and to erase, a command holds once typed, and the pause before the next. */
|
||
const TYPE = 55
|
||
const ERASE = 22
|
||
const HOLD = 2400
|
||
const GAP = 280
|
||
|
||
/**
|
||
* The tour: the block below the install line types each command of `TOUR` in
|
||
* turn under its own label, holds it, erases it and types the next, so a
|
||
* reader learns the CLI while they read the page. The Copy button copies the
|
||
* command shown. It waits while the pointer or the keyboard is on it, while it
|
||
* is off screen, and for good under reduced motion, where Claude Code's
|
||
* command stands as it always did.
|
||
*/
|
||
function Tour({ onCopy }: { onCopy: (command: string) => void }) {
|
||
const box = useRef<GuiElement>(null)
|
||
const still = useStill()
|
||
const [at, setAt] = useState({ i: 0, n: TOUR[0].command.length })
|
||
const held = useRef(false)
|
||
const seen = useRef(true)
|
||
useEffect(() => {
|
||
if (still) return
|
||
const el = node(box)
|
||
const io = el ? new IntersectionObserver(([e]) => (seen.current = !!e?.isIntersecting)) : null
|
||
if (el && io) io.observe(el)
|
||
let i = 0
|
||
let n = TOUR[0].command.length
|
||
let phase: 'hold' | 'erase' | 'type' = 'hold'
|
||
let t = 0
|
||
const tick = () => {
|
||
if (held.current || !seen.current) return void (t = window.setTimeout(tick, 400))
|
||
const full = TOUR[i].command.length
|
||
if (phase === 'hold') {
|
||
phase = 'erase'
|
||
return void (t = window.setTimeout(tick, ERASE))
|
||
}
|
||
if (phase === 'erase') {
|
||
if (n > 0) {
|
||
n -= 1
|
||
setAt({ i, n })
|
||
return void (t = window.setTimeout(tick, ERASE))
|
||
}
|
||
i = (i + 1) % TOUR.length
|
||
phase = 'type'
|
||
setAt({ i, n: 0 })
|
||
return void (t = window.setTimeout(tick, GAP))
|
||
}
|
||
if (n < full) {
|
||
n += 1
|
||
setAt({ i, n })
|
||
return void (t = window.setTimeout(tick, TYPE))
|
||
}
|
||
phase = 'hold'
|
||
t = window.setTimeout(tick, HOLD)
|
||
}
|
||
t = window.setTimeout(tick, HOLD)
|
||
return () => {
|
||
window.clearTimeout(t)
|
||
io?.disconnect()
|
||
}
|
||
}, [still])
|
||
const step = TOUR[at.i]
|
||
return (
|
||
<View
|
||
ref={box}
|
||
data-slot="tour"
|
||
onPointerEnter={() => (held.current = true)}
|
||
onPointerLeave={() => (held.current = false)}
|
||
onFocus={() => (held.current = true)}
|
||
onBlur={() => (held.current = false)}
|
||
>
|
||
<Block label={step.label} lines={[step.command.slice(0, at.n)]} copy={step.command} caret={!still} onCopy={() => onCopy(step.command)} />
|
||
</View>
|
||
)
|
||
}
|
||
|
||
const CopyPrompt = dynamic(() => import('@/components/home/CopyPrompt'), { ssr: false })
|
||
|
||
/** Every other coding agent: the menu that hands the setup prompt to one, its marks loaded after the first screen. */
|
||
function Agents() {
|
||
const { t } = useI18n()
|
||
return (
|
||
<YStack gap={6}>
|
||
<Text fontSize="$2" lineHeight="$2" fontWeight="500" color="var(--foreground)">
|
||
{t('Set up Codex, Cursor and more')}
|
||
</Text>
|
||
<CopyPrompt />
|
||
</YStack>
|
||
)
|
||
}
|
||
|
||
/** A block of commands with a button that copies all of it. `label` is authored copy (`phrase`), translated here; the commands are not. */
|
||
function Block({ label, lines, wrap, copy, caret, onCopy }: { label: string; lines: readonly string[]; wrap?: boolean; copy?: string; caret?: boolean; onCopy: () => void }) {
|
||
const { t } = useI18n()
|
||
const [done, setDone] = useState<'copied' | 'failed' | null>(null)
|
||
const text = copy ?? lines.join('\n')
|
||
const name = t(label)
|
||
return (
|
||
<YStack gap={6}>
|
||
<XStack items="center" justify="space-between" gap={12}>
|
||
<Text fontSize="$2" lineHeight="$2" fontWeight="500" color="var(--foreground)">
|
||
{name}
|
||
</Text>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
rounded={9999}
|
||
$platform-web={{ borderRadius: '9999px' }}
|
||
aria-label={done === 'copied' ? t('Copied') : t('Copy: {label}', { label: name })}
|
||
onPress={async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(text)
|
||
setDone('copied')
|
||
onCopy()
|
||
} catch {
|
||
setDone('failed')
|
||
}
|
||
window.setTimeout(() => setDone(null), 1800)
|
||
}}
|
||
>
|
||
<XStack items="center" gap={6}>
|
||
{done === 'copied' ? <Check size={14} strokeWidth={1.75} aria-hidden /> : <CopyIcon size={14} strokeWidth={1.5} aria-hidden />}
|
||
<Text fontSize="$1" color="inherit" role="status">
|
||
{done === 'copied' ? t('Copied') : done === 'failed' ? t('Select and copy') : t('Copy')}
|
||
</Text>
|
||
</XStack>
|
||
</Button>
|
||
</XStack>
|
||
<View
|
||
render="pre"
|
||
m={0}
|
||
px={12}
|
||
py={10}
|
||
borderWidth={1}
|
||
borderColor="var(--border)"
|
||
rounded={12}
|
||
bg="var(--surface-card-quiet, var(--card))"
|
||
$platform-web={{ overflowX: 'auto', whiteSpace: wrap ? 'pre-wrap' : 'pre', overflowWrap: wrap ? 'anywhere' : 'normal' }}
|
||
>
|
||
{lines.map((l, i) => (
|
||
<Text
|
||
key={i}
|
||
render="code"
|
||
display="block"
|
||
fontFamily="$mono"
|
||
fontSize="$2"
|
||
lineHeight="1.35rem"
|
||
color={wrap ? 'var(--muted-foreground)' : 'var(--foreground)'}
|
||
$platform-web={{ whiteSpace: wrap ? 'pre-wrap' : 'pre' }}
|
||
>
|
||
{wrap ? (
|
||
l
|
||
) : (
|
||
<>
|
||
<Text render="span" aria-hidden color="var(--muted-foreground)" $platform-web={{ userSelect: 'none' }}>
|
||
${' '}
|
||
</Text>
|
||
{l}
|
||
{caret ? (
|
||
<Text render="span" aria-hidden data-motion="blink" color="var(--foreground)" $platform-web={{ userSelect: 'none' }}>
|
||
▍
|
||
</Text>
|
||
) : null}
|
||
</>
|
||
)}
|
||
</Text>
|
||
))}
|
||
</View>
|
||
</YStack>
|
||
)
|
||
}
|
||
|
||
/** The way in: the sign-up for a stranger, the app for a member. */
|
||
function Door() {
|
||
const { t } = useI18n()
|
||
const { isLoading, isAuthenticated } = useIam()
|
||
const member = !isLoading && isAuthenticated
|
||
const counted = useRef(false)
|
||
useEffect(() => {
|
||
if (isLoading || isAuthenticated || counted.current) return
|
||
counted.current = true
|
||
track('signup_viewed', { mode: 'signup' })
|
||
}, [isLoading, isAuthenticated])
|
||
if (member) {
|
||
return (
|
||
<YStack gap={12} items="center" py={24}>
|
||
<Text fontSize="$4" color="var(--foreground)">
|
||
{t('You are signed in.')}
|
||
</Text>
|
||
<Action href="/" fill>
|
||
{t('Open Hanzo')}
|
||
</Action>
|
||
</YStack>
|
||
)
|
||
}
|
||
return (
|
||
<>
|
||
<Panel mode="signup" />
|
||
<Text render="p" maxW={420} fontSize="$1" lineHeight="$1" color="var(--muted-foreground)" text="center">
|
||
{t("By continuing, you agree to Hanzo's {terms} and {privacy}.", {
|
||
terms: <TextLink href="/terms">{t('Terms')}</TextLink>,
|
||
privacy: <TextLink href="/privacy">{t('Privacy Policy')}</TextLink>,
|
||
})}
|
||
</Text>
|
||
</>
|
||
)
|
||
}
|
||
|
||
/** How far the ink dims away from the light, as the percent of ink left: 100 is solid. */
|
||
const DIM = 30
|
||
|
||
/**
|
||
* "Pick all three." in the page's ink, and a light on it (`[data-flash]` in
|
||
* app/layout.tsx): the ink dims to 30% and full ink stands where the light is.
|
||
* The light crosses the line once as the page opens, then follows the pointer
|
||
* while it is over the words and lets the ink back when it leaves. Under reduced
|
||
* motion it never crosses on its own, and it tracks the pointer without easing.
|
||
*/
|
||
function Three() {
|
||
const { t } = useI18n()
|
||
const ref = useRef<HTMLSpanElement>(null)
|
||
const still = useStill()
|
||
const light = useRef({ x: -1e4, ink: 100, tx: -1e4, tink: 100, sweep: 0, frame: 0 })
|
||
const paint = () => {
|
||
const el = ref.current
|
||
const l = light.current
|
||
if (!el) return
|
||
el.style.setProperty('--x', `${l.x.toFixed(1)}px`)
|
||
el.style.setProperty('--ink', `${l.ink.toFixed(1)}%`)
|
||
}
|
||
const run = () => {
|
||
const l = light.current
|
||
if (l.frame) return
|
||
const step = (now: number) => {
|
||
const el = ref.current
|
||
if (!el) return void (l.frame = 0)
|
||
if (l.sweep) {
|
||
const w = el.getBoundingClientRect().width
|
||
const t = Math.min(1, (now - l.sweep) / 1200)
|
||
const e = t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2
|
||
l.tx = -0.25 * w + e * 1.5 * w
|
||
l.tink = t < 0.85 ? DIM : 100
|
||
if (t === 1) l.sweep = 0
|
||
}
|
||
l.x += (l.tx - l.x) * (l.sweep ? 1 : 0.24)
|
||
l.ink += (l.tink - l.ink) * 0.14
|
||
paint()
|
||
const settled = !l.sweep && Math.abs(l.tx - l.x) < 0.5 && Math.abs(l.tink - l.ink) < 0.5
|
||
l.frame = settled ? 0 : requestAnimationFrame(step)
|
||
}
|
||
l.frame = requestAnimationFrame(step)
|
||
}
|
||
useEffect(() => {
|
||
const l = light.current
|
||
if (still) return
|
||
l.sweep = performance.now()
|
||
run()
|
||
return () => {
|
||
cancelAnimationFrame(l.frame)
|
||
l.frame = 0
|
||
}
|
||
}, [still])
|
||
const at = (e: PointerEvent<HTMLSpanElement>, ink: number) => {
|
||
const el = ref.current
|
||
const l = light.current
|
||
if (!el) return
|
||
l.sweep = 0
|
||
l.tx = e.clientX - el.getBoundingClientRect().left
|
||
l.tink = ink
|
||
if (still) {
|
||
l.x = l.tx
|
||
l.ink = ink
|
||
paint()
|
||
} else run()
|
||
}
|
||
return (
|
||
<span
|
||
ref={ref}
|
||
data-flash=""
|
||
onPointerEnter={(e) => at(e, DIM)}
|
||
onPointerMove={(e) => at(e, DIM)}
|
||
onPointerLeave={(e) => at(e, 100)}
|
||
>
|
||
{t('Pick all three.')}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Sets each line of the title in the hero's column to the measure, the width
|
||
* of the sign-in card (`--w`, `[data-measure]` in app/layout.tsx). The
|
||
* stylesheet's sizes come within a few pixels, since glyphs round differently
|
||
* at each size (five pixels at 320 wide); this finds the largest size whose
|
||
* line is no wider than the measure, once the face has loaded and again
|
||
* whenever the title's width or its words (`words`, the catalog it is read in)
|
||
* change.
|
||
*/
|
||
function useFit(column: { current: GuiElement | null }, words: unknown) {
|
||
useEffect(() => {
|
||
const h = node(column)?.querySelector<HTMLElement>('[data-fit]')
|
||
if (!h) return
|
||
let width = -1
|
||
const fit = () => {
|
||
const w = parseFloat(getComputedStyle(h).getPropertyValue('--w'))
|
||
if (!(w > 0)) return
|
||
for (const line of h.querySelectorAll<HTMLElement>('[data-line]')) {
|
||
const range = document.createRange()
|
||
range.selectNodeContents(line)
|
||
const at = (size: number) => {
|
||
line.style.fontSize = `${size.toFixed(3)}px`
|
||
return range.getBoundingClientRect().width
|
||
}
|
||
const near = parseFloat(getComputedStyle(line).fontSize)
|
||
const drawn = range.getBoundingClientRect().width
|
||
if (!(drawn > 0)) continue
|
||
// The largest size whose line is no wider than the measure.
|
||
let lo = (near * w) / drawn / 1.1
|
||
let hi = (near * w) / drawn * 1.1
|
||
for (let i = 0; i < 12; i++) {
|
||
const mid = (lo + hi) / 2
|
||
if (at(mid) <= w + 0.25) lo = mid
|
||
else hi = mid
|
||
}
|
||
at(lo)
|
||
}
|
||
}
|
||
const watch = new ResizeObserver(() => {
|
||
if (h.clientWidth === width) return
|
||
width = h.clientWidth
|
||
fit()
|
||
})
|
||
watch.observe(h)
|
||
document.fonts?.ready.then(fit)
|
||
return () => watch.disconnect()
|
||
}, [column, words])
|
||
}
|
||
|
||
export default function Start() {
|
||
const { t, messages } = useI18n()
|
||
const column = useRef<GuiElement>(null)
|
||
useFit(column, messages)
|
||
const events = useAnalytics()
|
||
const seen = useRef(false)
|
||
useEffect(() => {
|
||
if (seen.current) return
|
||
seen.current = true
|
||
events.capture('home_viewed')
|
||
}, [events])
|
||
return (
|
||
<View
|
||
render="section"
|
||
data-slot="hero"
|
||
aria-labelledby="home-title"
|
||
position="relative"
|
||
width="100%"
|
||
overflow="hidden"
|
||
// The first screen is the whole window under the bar and the beta strip, its content in the middle, so the
|
||
// row of backers is the first thing the scroll shows. `svh`: a phone's address bar does not move it.
|
||
display="flex"
|
||
flexDirection="column"
|
||
justify="center"
|
||
minH={`calc(100svh - ${ROOF} - ${STRIP}px)`}
|
||
$platform-web={{ overflowX: 'clip' }}
|
||
>
|
||
<Opening />
|
||
<XStack
|
||
position="relative"
|
||
width="100%"
|
||
maxW={1320}
|
||
mx="auto"
|
||
px="var(--page-gutter)"
|
||
pt="max(48px, min(9vh, 96px))"
|
||
pb="max(48px, min(8vh, 88px))"
|
||
gap="max(32px, min(5vw, 72px))"
|
||
items="center"
|
||
$max-lg={{ flexDirection: 'column', items: 'stretch' }}
|
||
>
|
||
<YStack ref={column} flex={6} minW={0} gap="$3" items="center" data-measure="" $max-lg={{ flexBasis: 'auto' }}>
|
||
<Label center href="/blog/pareto-frontier">{t('A New Pareto Frontier')}</Label>
|
||
<Display id="home-title" data-fit="" width="100%" text="center" mt={4}>
|
||
<span data-line="1">{t('Better, cheaper, faster.')}</span>{' '}
|
||
<span data-line="2">
|
||
<Three />
|
||
</span>
|
||
</Display>
|
||
<Lede text="center" mb={8} $platform-web={{ textWrap: 'balance', lineHeight: '1.45' }}>
|
||
{t('Every model behind one key.')}
|
||
</Lede>
|
||
<Door />
|
||
</YStack>
|
||
<YStack flex={5} minW={0} position="relative" $max-lg={{ flexBasis: 'auto' }}>
|
||
<Win title="Hanzo.Start" icon={<HanzoMark size={14} />} slot="start" open>
|
||
<YStack p={16} gap={16}>
|
||
{BLOCKS.map((b) => (
|
||
<Block key={b.key} label={b.label} lines={b.lines} onCopy={() => events.capture('home_copied', { block: b.key })} />
|
||
))}
|
||
<Tour onCopy={(command) => events.capture('home_copied', { block: 'tour', command })} />
|
||
<Agents />
|
||
</YStack>
|
||
</Win>
|
||
</YStack>
|
||
</XStack>
|
||
</View>
|
||
)
|
||
}
|