Files
hanzo.ai/components/home/Products.tsx
zeekay 0f5536b707 hanzo.ai reads in the reader's language, and Arabic right to left
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.
2026-10-22 19:54:31 -07:00

483 lines
21 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useEffect, useRef, useState } from 'react'
import { ArrowUpRight, Check, Clock, Loader2 } from 'lucide-react'
import { phrase } from '@hanzo/i18n'
import { useI18n } from '@hanzo/i18n/react'
import { Text, View, XStack, YStack, type GuiElement } from '@hanzo/ui'
import { WORKSPACE } from '@hanzo/app/lib/host'
import { Section, node, useGrow, useSeen, useStill } from './chrome'
/**
* The products: modern visual preview panels matching the Hanzo ecosystem
* arranged in a clean, balanced responsive grid (3 on top, 2 on bottom).
*
* Each scene is authored sample copy, so it is drawn in the reader's language
* (`phrase` marks the lines written as data); a product's name, a person's name,
* a channel, a file path and the diff's code stay as written, and the diff reads
* left to right on every page, as code does.
*/
const PRODUCTS = [
{
key: 'chat',
name: 'Chat',
tag: 'Hanzo Chat',
href: '/chat',
top: true,
},
{
key: 'dev',
name: 'Dev',
tag: 'Hanzo Dev',
href: '/dev',
top: true,
},
{
key: 'bot',
name: 'Bot',
tag: 'Hanzo Bot',
href: 'https://hanzo.bot',
top: true,
},
{
key: 'studio',
name: 'Studio',
tag: 'Hanzo Studio',
href: '/studio',
top: false,
},
{
key: 'team',
name: 'Team',
tag: 'Hanzo Team',
href: WORKSPACE,
top: false,
},
] as const
/**
* A card's scene: step 0 to `beats.length`, each `beats[i]` milliseconds after
* the last, then held for `rest` and played again. It starts when the card is
* first seen; before that, and under reduced motion, it stands at its last
* step, whole.
*/
function useScene(beats: readonly number[], play: boolean, still: boolean, rest = 2800) {
const end = beats.length
const [at, setAt] = useState(end)
useEffect(() => {
if (still || !play) return void setAt(end)
let k = 0
let t = 0
setAt(0)
const next = () => {
k = k >= end ? 0 : k + 1
setAt(k)
t = window.setTimeout(next, k >= end ? rest : beats[k] ?? 600)
}
t = window.setTimeout(next, beats[0] ?? 600)
return () => window.clearTimeout(t)
}, [play, still, end, rest, beats])
return at
}
/** Three dots that pulse in turn: someone is typing (`[data-typing]` in app/layout.tsx). */
function Typing() {
return (
<XStack data-typing="" aria-hidden gap={4} px={14} py={12} rounded={18} bg="var(--white-05)" self="flex-start" $platform-web={{ borderRadius: '18px' }}>
{[0, 1, 2].map((d) => (
<View key={d} width={6} height={6} rounded={9999} bg="var(--muted-foreground)" $platform-web={{ borderRadius: '9999px' }} />
))}
</XStack>
)
}
/** One chat bubble: the person's at the end of the line, the answer at its start. */
function Bubble({ mine, children }: { mine?: boolean; children: string }) {
return (
<XStack data-motion="rise" justify={mine ? 'flex-end' : 'flex-start'}>
<View px={14} py={mine ? 8 : 10} rounded={18} bg={mine ? 'var(--secondary)' : 'var(--white-05)'} maxW={mine ? '88%' : '92%'} $platform-web={{ borderRadius: '18px' }}>
<Text fontSize="$2" lineHeight="$3" color={mine ? 'var(--foreground)' : 'var(--muted-foreground)'}>
{children}
</Text>
</View>
</XStack>
)
}
const CHAT = [
{ mine: true, text: phrase('How do black holes form?') },
{ mine: false, text: phrase('A massive star runs out of fuel and gravity collapses its core into a singularity.') },
{ mine: true, text: phrase('What causes the aurora?') },
{ mine: false, text: phrase('Solar particles hit gases high over the poles and make them glow.') },
] as const
/** Each message, an answer after a moment of typing. */
const CHAT_BEATS = [500, 900, 1200, 1500, 900, 1200] as const
function ChatPreview({ play, still }: { play: boolean; still: boolean }) {
const { t } = useI18n()
const at = useScene(CHAT_BEATS, play, still)
// Steps: 1 ask, 2 typing, 3 answer, 4 ask, 5 typing, 6 answer.
const shown = [at >= 1, at >= 3, at >= 4, at >= 6]
const typing = at === 2 || at === 5
return (
<YStack p={16} gap={10} flex={1} justify="flex-end" overflow="hidden" minH={300}>
{CHAT.map((m, k) => (shown[k] ? <Bubble key={k} mine={m.mine}>{t(m.text)}</Bubble> : null))}
{typing ? <Typing /> : null}
</YStack>
)
}
const DIFF = [
{ n: 42, kind: 'same', text: 'export async function handler(req) {' },
{ n: 43, kind: 'add', text: ' const token = extractBearer(req);' },
{ n: 44, kind: 'add', text: ' if (!token) return unauthorized();' },
{ n: 47, kind: 'cut', text: ' const session = await getSession(req);' },
{ n: 48, kind: 'add', text: ' req.user = await verify(token);' },
{ n: 49, kind: 'add', text: ' return next(req);' },
] as const
/** Thinking, the file opened, then each line of the diff. */
const DEV_BEATS = [700, 900, 500, 450, 450, 450, 450, 450, 450, 900] as const
function DevPreview({ play, still }: { play: boolean; still: boolean }) {
const { t } = useI18n()
const at = useScene(DEV_BEATS, play, still)
const lines = Math.max(0, Math.min(DIFF.length, at - 2))
const done = at >= DEV_BEATS.length
return (
<YStack p={16} gap={8} flex={1} justify="center" overflow="hidden" minH={300}>
<View rounded={12} bg="var(--surface-card-quiet, var(--card))" borderWidth={1} borderColor="var(--border)" overflow="hidden" $platform-web={{ borderRadius: '12px' }}>
<XStack px={10} py={6} bg="var(--white-03)" items="center" justify="space-between" borderBottomWidth={1} borderColor="var(--border)">
<XStack items="center" gap={6}>
<View width={8} height={8} rounded={9999} bg="var(--chrome-dot-red)" $platform-web={{ borderRadius: '9999px' }} />
<View width={8} height={8} rounded={9999} bg="var(--chrome-dot-yellow)" $platform-web={{ borderRadius: '9999px' }} />
<View width={8} height={8} rounded={9999} bg="var(--chrome-dot-green)" $platform-web={{ borderRadius: '9999px' }} />
<Text fontFamily="$mono" fontSize="$1" color="var(--muted-foreground)" marginInlineStart={6}>
projects/main
</Text>
</XStack>
<View width={56} height={4} rounded={9999} bg="var(--white-10)" overflow="hidden" $platform-web={{ borderRadius: '9999px' }}>
<View height="100%" bg="var(--state-online)" width={`${Math.round((at / DEV_BEATS.length) * 100)}%`} $platform-web={{ borderRadius: '9999px', transition: 'width 400ms ease' }} />
</View>
</XStack>
<XStack px={10} py={5} items="center" gap={6} borderBottomWidth={1} borderColor="var(--white-05)" minH={26}>
<Text fontFamily="$mono" fontSize="$1" color="var(--art-iris)">
{at >= 1 ? <>▾ {t('thought for {time}', { time: '4.1s' })}</> : t('thinking…')}
</Text>
</XStack>
<XStack px={10} py={5} items="center" gap={6} minH={26}>
{at >= 2 ? (
<>
<Text fontFamily="$mono" fontSize="$1" color="var(--state-online)">
◆ {t('Edit')}
</Text>
<Text fontFamily="$mono" fontSize="$1" color="var(--muted-foreground)">
src/middleware/auth.ts
</Text>
</>
) : null}
</XStack>
<YStack px={8} pb={6} minH={132} data-i18n-ltr>
{DIFF.slice(0, lines).map((l) => (
<XStack
key={l.n}
data-motion="rise"
gap={8}
px={4}
rounded={4}
bg={l.kind === 'same' ? 'transparent' : l.kind === 'cut' ? 'var(--state-error-bg)' : 'var(--white-10)'}
$platform-web={{ borderRadius: '4px' }}
>
<Text fontFamily="$mono" fontSize="$1" width={18} color={l.kind === 'cut' ? 'var(--state-error)' : l.kind === 'add' ? 'var(--state-online)' : 'var(--muted-foreground)'} opacity={l.kind === 'same' ? 0.5 : 1}>
{l.n}
</Text>
<Text fontFamily="$mono" fontSize="$1" color={l.kind === 'cut' ? 'var(--state-error)' : l.kind === 'add' ? 'var(--state-online)' : 'var(--foreground)'} $platform-web={{ whiteSpace: 'pre', textDecoration: l.kind === 'cut' ? 'line-through' : 'none' }}>
{l.text}
</Text>
</XStack>
))}
</YStack>
<XStack px={10} py={6} bg="var(--white-03)" borderTopWidth={1} borderColor="var(--border)" items="center" gap={6}>
<Text fontFamily="$mono" fontSize="$1" color={done ? 'var(--state-online)' : 'var(--foreground)'}>
{done ? <>✓ {t('Tests pass · 4 lines changed')}</> : <>› {t('Add rate limiting to all API routes.')}</>}
</Text>
</XStack>
</View>
</YStack>
)
}
const BOT_STEPS = [
phrase('Read the month’s card charges'),
phrase('Matched each charge to its receipt'),
phrase('Asked about the ones with no receipt'),
phrase('Filed the month-end report'),
] as const
/** Each step runs, then the next. */
const BOT_BEATS = [600, 1300, 1300, 1500, 1300] as const
function BotPreview({ play, still }: { play: boolean; still: boolean }) {
const { t } = useI18n()
const at = useScene(BOT_BEATS, play, still)
return (
<YStack p={20} gap={14} flex={1} justify="center" minH={300}>
<XStack items="center" gap={10} px={14} py={8} rounded={9999} bg="var(--secondary)" self="flex-start" $platform-web={{ borderRadius: '9999px' }}>
<Clock size={14} color="var(--foreground)" />
<Text fontFamily="$mono" fontSize="$1" color="var(--foreground)">
{t('Routine · Month-end close')}
</Text>
</XStack>
<YStack gap={10}>
{BOT_STEPS.map((step, k) => {
const state = at > k + 1 ? 'done' : at === k + 1 ? 'doing' : 'waiting'
return (
<XStack key={step} items="center" gap={10} opacity={state === 'waiting' ? 0.4 : 1} $platform-web={{ transition: 'opacity 300ms ease' }}>
<View width={20} height={20} shrink={0} rounded={9999} items="center" justify="center" borderWidth={1} borderColor={state === 'done' ? 'var(--state-online)' : 'var(--border-strong)'} $platform-web={{ borderRadius: '9999px' }}>
{state === 'done' ? <Check size={12} color="var(--state-online)" strokeWidth={2.4} /> : state === 'doing' ? <Loader2 data-spin="" size={12} color="var(--foreground)" /> : null}
</View>
<Text fontSize="$2" color={state === 'doing' ? 'var(--foreground)' : 'var(--muted-foreground)'}>
{t(step)}
</Text>
</XStack>
)
})}
</YStack>
<Text fontFamily="$mono" fontSize="$1" color="var(--muted-foreground)" minH={18}>
{at >= BOT_BEATS.length ? t('Done. 2 questions waiting for you.') : t('Working…')}
</Text>
</YStack>
)
}
/**
* Studio's footage: the creative models' paintings (public/images/models),
* one after another, each easing in and drifting slowly as it holds, named by
* the model it shows. It moves only while it is on screen; a reader who asked
* for less motion sees the first, still.
*/
const REEL = [
{ src: '/images/models/iro.webp', model: 'Iro', kind: phrase('Image'), alt: phrase("Iro, Hanzo's image model") },
{ src: '/images/models/koma.webp', model: 'Koma', kind: phrase('Video'), alt: phrase("Koma, Hanzo's video model") },
{ src: '/images/models/mori.webp', model: 'Mori', kind: phrase('3D'), alt: phrase("Mori, Hanzo's 3d model") },
{ src: '/images/models/oto.webp', model: 'Oto', kind: phrase('Sound'), alt: phrase("Oto, Hanzo's sound model") },
{ src: '/images/models/koe.webp', model: 'Koe', kind: phrase('Voice'), alt: phrase("Koe, Hanzo's voice model") },
{ src: '/images/models/uta.webp', model: 'Uta', kind: phrase('Music'), alt: phrase("Uta, Hanzo's music model") },
] as const
/** Milliseconds each still holds before the next has fully come in. */
const HOLD = 2600
function StudioPreview({ play, still }: { play: boolean; still: boolean }) {
const { t } = useI18n()
const reel = useRef<GuiElement>(null)
useEffect(() => {
const box = node(reel)
if (!box || still || !play || typeof box.animate !== 'function') return
const cut = REEL.length * HOLD
const frames = [...box.querySelectorAll<HTMLElement>('[data-shot]')]
const runs = frames.map((el, i) =>
el.animate(
[
{ opacity: 0, transform: 'scale(1.08) translate3d(2%, 1%, 0)', offset: 0 },
{ opacity: 1, transform: 'scale(1.05) translate3d(1%, 0.5%, 0)', offset: 0.08 },
{ opacity: 1, transform: 'scale(1) translate3d(0, 0, 0)', offset: 1 / REEL.length },
{ opacity: 0, transform: 'scale(1) translate3d(0, 0, 0)', offset: Math.min(1, 1 / REEL.length + 0.08) },
{ opacity: 0, transform: 'scale(1) translate3d(0, 0, 0)', offset: 1 },
],
{ duration: cut, delay: i * HOLD, iterations: Infinity, easing: 'ease-in-out', fill: 'both' },
),
)
const io = new IntersectionObserver(([e]) => runs.forEach((r) => (e?.isIntersecting ? r.play() : r.pause())))
io.observe(box)
return () => {
io.disconnect()
runs.forEach((r) => r.cancel())
}
}, [play, still])
return (
<View flex={1} minH={300}>
<View ref={reel} flex={1} width="100%" overflow="hidden" position="relative" bg="var(--pure-black)" data-reel="">
{REEL.map((f, i) => (
<View key={f.src} data-shot="" position="absolute" t={0} l={0} r={0} b={0} opacity={i === 0 ? 1 : 0}>
<View
render={<img src={f.src} alt={t(f.alt)} loading="lazy" />}
$platform-web={{ objectFit: 'cover', display: 'block', width: '100%', height: '100%' }}
/>
<XStack position="absolute" insetInlineStart={12} b={12} items="center" gap={6} px={10} py={4} rounded={9999} $platform-web={{ borderRadius: '9999px', backdropFilter: 'blur(8px)', backgroundColor: 'color-mix(in srgb, var(--pure-black) 60%, transparent)' }}>
<Text fontFamily="$mono" fontSize="$1" fontWeight="600" color="var(--pure-white)">
{f.model}
</Text>
<Text fontFamily="$mono" fontSize="$1" color="var(--white-80)">
{t(f.kind)}
</Text>
</XStack>
</View>
))}
</View>
</View>
)
}
/**
* Team's room: the team and their agent talking in one channel, a message at a
* time with someone typing between, the last four in view.
*/
const PEOPLE = [
{ who: 'Ada', tone: 'var(--art-iris)', agent: false },
{ who: 'Lin', tone: 'var(--art-tide)', agent: false },
{ who: 'Sam', tone: 'var(--art-ember)', agent: false },
{ who: 'Grace', tone: 'var(--state-online)', agent: false },
{ who: 'Kai', tone: 'var(--foreground)', agent: true },
] as const
const THREAD = [
{ who: 0, says: phrase('Pricing page is ready for review.') },
{ who: 1, says: phrase('Copy reads well. One nit on the hero.') },
{ who: 4, says: phrase('Tests pass on main. Deploy to production?') },
{ who: 3, says: phrase('Approved. Ship it.') },
{ who: 2, says: phrase('Release notes are in the doc.') },
{ who: 0, says: phrase('Live. Thanks, everyone.') },
] as const
/** Typing, then the message, for each. */
const TEAM_BEATS = THREAD.flatMap(() => [700, 900])
function TeamPreview({ play, still }: { play: boolean; still: boolean }) {
const { t } = useI18n()
const at = useScene(TEAM_BEATS, play, still, 3200)
const posted = Math.floor(at / 2)
const typing = at % 2 === 1 && posted < THREAD.length ? PEOPLE[THREAD[posted].who] : null
const view = THREAD.slice(0, posted).slice(-4)
return (
<YStack p={16} gap={12} flex={1} justify="flex-end" data-team="" minH={300}>
<XStack items="center" gap={8}>
{/* A channel's name in the sample room: a name, not copy, read in its own order. */}
<Text fontFamily="$mono" fontSize="$1" color="var(--muted-foreground)" data-i18n-ltr {...{ translate: 'no' }}>
# launch
</Text>
<View flex={1} height={1} bg="var(--white-06)" />
<XStack>
{PEOPLE.map((p, k) => (
<View key={p.who} width={22} height={22} marginInlineStart={k ? -6 : 0} rounded={9999} items="center" justify="center" borderWidth={2} borderColor="$panel" bg={p.agent ? 'var(--foreground)' : 'var(--secondary)'} $platform-web={{ borderRadius: '9999px' }}>
<Text fontSize={10} fontWeight="700" color={p.agent ? 'var(--background)' : p.tone}>
{p.who[0]}
</Text>
</View>
))}
</XStack>
</XStack>
<YStack gap={12} minH={220} justify="flex-end">
{view.map((m) => {
const p = PEOPLE[m.who]
return (
<XStack key={`${m.who}-${m.says}`} data-motion="rise" gap={10} items="flex-start">
<View width={28} height={28} shrink={0} rounded={9999} items="center" justify="center" bg={p.agent ? 'var(--foreground)' : 'var(--secondary)'} $platform-web={{ borderRadius: '9999px' }}>
<Text fontSize="$1" fontWeight="700" color={p.agent ? 'var(--background)' : p.tone}>
{p.who[0]}
</Text>
</View>
<YStack gap={2} flex={1} minW={0}>
<XStack items="center" gap={6}>
<Text fontSize="$2" fontWeight="600" color="var(--foreground)">
{p.who}
</Text>
{p.agent ? (
<Text fontFamily="$mono" fontSize="$1" px={6} rounded={6} bg="var(--white-08)" color="var(--muted-foreground)">
{t('agent')}
</Text>
) : null}
</XStack>
<Text fontSize="$2" lineHeight="$3" color="var(--muted-foreground)">
{t(m.says)}
</Text>
</YStack>
</XStack>
)
})}
<Text fontFamily="$mono" fontSize="$1" color="var(--muted-foreground)" minH={18}>
{typing ? t('{who} is typing…', { who: typing.who }) : ''}
</Text>
</YStack>
</YStack>
)
}
function Card({ p, i, last, play, still }: { p: (typeof PRODUCTS)[number]; i: number; last: boolean; play: boolean; still: boolean }) {
const { t } = useI18n()
const ref = useGrow(play, still, { delay: i * 70, duration: 550, from: 'translateY(16px)', to: 'translateY(0px)' })
return (
<View
ref={ref}
data-product={p.key}
display="flex"
flexDirection="column"
// Two columns from 640px, the last tile across both; six from 1024px, three tiles over two.
$sm={last ? { gridColumn: 'span 2' } : undefined}
$lg={{ gridColumn: p.top ? 'span 2' : 'span 3' }}
>
<YStack
flex={1}
minH={380}
rounded={24}
bg="$panel"
borderWidth={1}
borderColor="var(--border)"
boxShadow="0 24px 60px -28px color-mix(in srgb, var(--pure-black) 45%, transparent)"
hoverStyle={{ borderColor: 'var(--border-strong)' }}
overflow="hidden"
justify="space-between"
$platform-web={{ borderRadius: '24px', transition: 'border-color 0.15s ease' }}
>
{p.key === 'chat' && <ChatPreview play={play} still={still} />}
{p.key === 'dev' && <DevPreview play={play} still={still} />}
{p.key === 'bot' && <BotPreview play={play} still={still} />}
{p.key === 'studio' && <StudioPreview play={play} still={still} />}
{p.key === 'team' && <TeamPreview play={play} still={still} />}
<XStack px={22} py={16} items="center" justify="space-between">
<Text fontSize="$5" lineHeight="$5" fontWeight="600" color="var(--foreground)">
{p.name}
</Text>
<XStack
render={<a href={p.href} />}
items="center"
gap={6}
opacity={0.75}
hoverStyle={{ opacity: 1 }}
$platform-web={{
textDecoration: 'none',
borderRadius: '9999px',
}}
>
<Text fontSize="$2" fontWeight="500" color="var(--foreground)">
{t('Explore')}
</Text>
<ArrowUpRight size={16} color="var(--foreground)" data-i18n-mirror />
</XStack>
</XStack>
</YStack>
</View>
)
}
export default function Products() {
const { t } = useI18n()
const [ref, seen] = useSeen(0.15)
const still = useStill()
return (
<Section id="products" label="Hanzo.Apps" title={t('One account. Every tool you build with.')} dither>
<View
ref={ref}
display="grid"
width="100%"
gridTemplateColumns="minmax(0, 1fr)"
gap={16}
$sm={{ gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 20 }}
$lg={{ gridTemplateColumns: 'repeat(6, minmax(0, 1fr))', gap: 24 }}
>
{PRODUCTS.map((p, i) => (
<Card key={p.key} p={p} i={i} last={i === PRODUCTS.length - 1} play={seen} still={still} />
))}
</View>
</Section>
)
}