mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
The row was 1440 wide and its scene ran past the bar's Try Hanzo at 1440 and wider; it takes the shell's MEASURE, as every page does.
221 lines
7.3 KiB
TypeScript
221 lines
7.3 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useRef } from 'react'
|
||
import { useAnalytics } from '@hanzo/event/react'
|
||
import { useI18n } from '@hanzo/i18n/react'
|
||
import { useIam } from '@hanzo/iam/react'
|
||
import { Text, View, XStack, YStack, type GuiElement } from '@hanzo/ui'
|
||
import { Action, Display, Lede, TextLink } from '@hanzo/ui/marketing'
|
||
import { Panel } from '@/components/auth/panel'
|
||
import { MEASURE, ROOF } from '@/components/shell/dock'
|
||
import { STRIP } from './beta'
|
||
import { Opening, node, useStill } from './chrome'
|
||
import { Label } from './label'
|
||
import BuildPreview from './BuildPreview'
|
||
|
||
/**
|
||
* The first screen: the promise and the way in beside the scene (laptop,
|
||
* terminal and phone, ./BuildPreview), which takes the rest of the row. Below
|
||
* 1024px the scene comes first and the way in under it.
|
||
*/
|
||
/** On a phone the scene is one phone, 9:16, as wide as the first screen under the bar lets it stand whole. */
|
||
const PHONE: `calc(${string})` = `calc(min(440px, (100svh - ${ROOF} - ${STRIP}px - 48px) * 9 / 16))`
|
||
|
||
function Door() {
|
||
const { t } = useI18n()
|
||
const { isLoading, isAuthenticated } = useIam()
|
||
const member = !isLoading && isAuthenticated
|
||
if (member) {
|
||
return (
|
||
<XStack gap={12} justify="center" flexWrap="wrap" py={24}>
|
||
<Action href="/chat" fill>
|
||
{t('Open Hanzo')}
|
||
</Action>
|
||
<Action href="https://docs.hanzo.ai">{t('Read docs')}</Action>
|
||
</XStack>
|
||
)
|
||
}
|
||
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>
|
||
</>
|
||
)
|
||
}
|
||
|
||
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 ? 30 : 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: any, 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, 30)}
|
||
onPointerMove={(e) => at(e, 30)}
|
||
onPointerLeave={(e) => at(e, 100)}
|
||
>
|
||
{t('Pick all three.')}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
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
|
||
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"
|
||
display="flex"
|
||
flexDirection="column"
|
||
justify="center"
|
||
minH={`calc(100svh - ${ROOF} - ${STRIP}px)`}
|
||
$platform-web={{ overflowX: 'clip' }}
|
||
>
|
||
<Opening />
|
||
<XStack
|
||
position="relative"
|
||
width="100%"
|
||
// The bar's measure: the scene's far edge stands where the bar's last control does.
|
||
maxW={MEASURE}
|
||
mx="auto"
|
||
px="var(--page-gutter)"
|
||
pt="max(40px, min(8vh, 88px))"
|
||
pb="max(40px, min(7vh, 80px))"
|
||
gap="max(32px, min(4vw, 64px))"
|
||
items="center"
|
||
// Side by side from 1280px only: narrower, the scene beside a 440px column would set its type below reading size.
|
||
$max-xl={{ flexDirection: 'column-reverse', items: 'center', gap: 40 }}
|
||
$max-sm={{ pt: 24, gap: 32 }}
|
||
>
|
||
<YStack ref={column} width={440} shrink={0} gap="$3" items="center" data-measure="" $max-xl={{ width: '100%' }}>
|
||
<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('Smarter, Faster, Open Source.')}</span>{' '}
|
||
<span data-line="2">
|
||
<Three />
|
||
</span>
|
||
</Display>
|
||
<Lede text="center" mb={8} $platform-web={{ textWrap: 'balance', lineHeight: '1.45' }}>
|
||
{t('Frontier intelligence up to {fast} faster and {cheap} cheaper. Every model behind one key.', { fast: <span data-num="">200×</span>, cheap: <span data-num="">400×</span> })}
|
||
</Lede>
|
||
<Door />
|
||
</YStack>
|
||
<YStack flex={1} minW={0} maxW={780} position="relative" $max-xl={{ flexBasis: 'auto', width: '100%', maxW: 780 }} $max-md={{ maxW: PHONE }} $max-sm={{ maxW: PHONE }}>
|
||
<BuildPreview />
|
||
</YStack>
|
||
</XStack>
|
||
</View>
|
||
)
|
||
}
|