mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
Every word the landing, the header menus, the phone sheet, the footer,
the help corner and the way in (sign-in, sign-up, the reset, the
invitation, the callback, onboarding, the 404) write goes through
@hanzo/i18n's t(): one sentence is one message, its figures, names and
links arguments; copy written as data is phrase('…') where written and
t(label) where drawn. Plans, the chart, the API states and the lines
lib/kai and lib/enso build for the landing do the same.
Right to left: <html lang dir> is set before the first paint (the
package's bootScript first in the head); a figure, clock, range or
price outside a sentence is isolated; sides are logical gui props, the
layout's [data-join] corners logical, a bar grows from the start;
arrows along the line mirror (Onward for "words ->" links). The repo
dates read in the reader's language.
Gates select by slot, never by an English label (rtl, help). New gate
translated.spec.ts: in Arabic the landing at 1440 and 390, every menu
open, the phone sheet, sign-in, sign-up and the app signed in hold no
authored English, nothing scrolls sideways, every range is isolated;
French the same for English's commonest words. What another package
draws with no words prop is listed by name (@hanzo/ui's Footer label,
@hanzo/build's composer row and suggestions).
@hanzo/i18n 0.5.3 (the catalogs: 1,443 keys, every offered locale
complete), @hanzo/app 0.2.1 (the app's words). The link gate imports
the terms versions from @hanzo/app, the one SDK main now resolves.
458 lines
17 KiB
TypeScript
458 lines
17 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useRef, useState, type PointerEvent } from 'react'
|
||
import dynamic from 'next/dynamic'
|
||
import { Activity, 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 { Opening, Win, node, useStill } from './chrome'
|
||
import { Glider } from './glider'
|
||
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" $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('Frontier AI up to 200× faster, 400× cheaper. Your favorite coding agents, accelerated. Build without compromise.')}
|
||
</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>
|
||
<View position="absolute" t={-118} insetInlineEnd={-20} display="none" $xl={{ display: 'flex' }}>
|
||
<Win title="Glider 1.0" icon={<Activity size={14} color="var(--background)" strokeWidth={2.2} />}>
|
||
<Glider href="/blog/synthesis" />
|
||
</Win>
|
||
</View>
|
||
</YStack>
|
||
</XStack>
|
||
</View>
|
||
)
|
||
}
|