Files
zeekay 724452f1a4 landing: the app cards on one ground, an accent each
Dev's console loses its window: the lines sit on the card's own ground
at the same 20px as every other card's scene. One accent per card, each
meaning something: the diff's added lines in success's ink and its
removed one in a destructive's, Bot's steps ticking off in success's,
Chat's own bubble a faint blue and Team's people each a soft hue from
@hanzo/tokens' data scales. The chrome stays monochrome, and the
hardcoded dark rungs (--white-05/06/08) give way to the theme's own
grounds, so light reads as dark does.
2026-10-30 06:40:46 -07:00

495 lines
20 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, type ReactNode } 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 { BAD, GOOD } from '@hanzo/app/lib/mix'
import { Section, node, useGrow, useLean, useSeen, useStill } from './chrome'
/**
* The products, three on top and two under them: each card one clear idea at a
* glance (owner, 2026-10-29: the cards stay simple; the full demos play on each
* app's page and in the landing's hero): Chat a short exchange, Dev a console
* session, Bot one routine, Studio its models' work, Team a channel. The name
* opens the app and Explore its page.
*
* 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',
page: '/chat',
top: true,
},
{
key: 'dev',
name: 'Dev',
tag: 'Hanzo Dev',
href: '/dev',
page: '/dev',
top: true,
},
{
key: 'bot',
name: 'Bot',
tag: 'Hanzo Bot',
href: 'https://hanzo.bot',
page: '/bot',
top: true,
},
{
key: 'studio',
name: 'Studio',
tag: 'Hanzo Studio',
href: '/studio',
page: '/studio',
top: false,
},
{
key: 'team',
name: 'Team',
tag: 'Hanzo Team',
href: WORKSPACE,
page: '/team',
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
}
/** Every card's scene stands this far in from its edges, so the five read as one family. */
const PAD = 20
/**
* A step of one of @hanzo/tokens' data scales (`@hanzo/tokens/css/data`, loaded
* by app/layout.tsx; dark at :root, retuned under .light): 3 a soft ground, 11
* legible ink. Named the way @hanzo/ui names them, so the hue is a word.
*/
const data = (hue: string, step: number): `var(--${string})` => `var(--data-${hue}-${step})`
/** The reader's own bubble in Chat: a faint tint of blue. */
const TINT = data('blue', 3)
/** 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="$hover" 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 ? TINT : '$hover'} maxW={mine ? '88%' : '92%'} $platform-web={{ borderRadius: '18px' }}>
<Text fontSize="$2" lineHeight="$3" color={mine ? '$ink' : '$soft'}>
{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 ChatCard({ 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={PAD} 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>
)
}
/**
* Dev's console: one ask at the prompt, then what the agent does, a line at a
* time, as a terminal prints it (the ask, the file read, the edit and its diff,
* the tests). The diff is code and reads left to right on every page.
*/
const DIFF = [
{ sign: ' ', text: 'function guard(req) {' },
{ sign: '-', text: ' return next(req)' },
{ sign: '+', text: ' const token = bearer(req)' },
{ sign: '+', text: ' if (!token) return deny()' },
{ sign: '+', text: ' req.user = await verify(token)' },
{ sign: '+', text: ' return next(req)' },
] as const
/** Read, edit, each line of the diff, the test run, the result. */
const DEV_BEATS = [800, 900, ...DIFF.map(() => 420), 900, 1300] as const
/** One line of the console: code keeps its indent on one line, the ask wraps. */
function Out({ children, ink = '$soft', wrap }: { children: ReactNode; ink?: '$ink' | '$soft' | '$faint' | typeof GOOD; wrap?: boolean }) {
return (
<Text data-motion="rise" display="block" fontFamily="$mono" fontSize="$2" lineHeight="$3" color={ink} overflow="hidden" $platform-web={{ whiteSpace: wrap ? 'pre-wrap' : 'pre', textOverflow: 'ellipsis' }}>
{children}
</Text>
)
}
function DevCard({ 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 tested = at >= 3 + DIFF.length
const passed = at >= DEV_BEATS.length
return (
<YStack p={PAD} gap={3} flex={1} justify="center" minH={300} data-i18n-ltr>
<Out ink="$ink" wrap>
$ dev &ldquo;{t('Require a token on every API route')}&rdquo;
</Out>
{at >= 1 ? <Out>● {t('Read')} src/middleware/auth.ts</Out> : null}
{at >= 2 ? <Out>● {t('Edit')} src/middleware/auth.ts</Out> : null}
{DIFF.slice(0, lines).map((l, k) => (
<Out key={k} ink={l.sign === '+' ? GOOD : l.sign === '-' ? BAD : '$faint'}>
{`${l.sign} ${l.text}`}
</Out>
))}
{tested ? <Out>● {t('Run')} pnpm test</Out> : null}
{passed ? <Out ink={GOOD}>✓ {t('Tests pass')}</Out> : !tested ? null : <Out ink="$faint">…</Out>}
</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 BotCard({ play, still }: { play: boolean; still: boolean }) {
const { t } = useI18n()
const at = useScene(BOT_BEATS, play, still)
return (
<YStack p={PAD} 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' ? GOOD : 'var(--border-strong)'} $platform-web={{ borderRadius: '9999px' }}>
{state === 'done' ? <Check size={12} color={GOOD} 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 StudioCard({ 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.
*/
/** Each person a hue of @hanzo/tokens' data scales (step 3 the ground, 11 the initial, both themed); the agent in ink. */
const PEOPLE = [
{ who: 'Ada', hue: 'iris', agent: false },
{ who: 'Lin', hue: 'cyan', agent: false },
{ who: 'Sam', hue: 'orange', agent: false },
{ who: 'Grace', hue: 'grass', agent: false },
{ who: 'Kai', hue: null, agent: true },
] as const
/** A person's avatar: its ground and its initial's ink. */
const tone = (p: (typeof PEOPLE)[number]): { bg: `var(--${string})`; ink: `var(--${string})` } =>
p.hue ? { bg: data(p.hue, 3), ink: data(p.hue, 11) } : { bg: 'var(--foreground)', ink: 'var(--background)' }
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])
export 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={PAD} 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="$edge" />
<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={tone(p).bg} $platform-web={{ borderRadius: '9999px' }}>
<Text fontSize={10} fontWeight="700" color={tone(p).ink}>
{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={tone(p).bg} $platform-web={{ borderRadius: '9999px' }}>
<Text fontSize="$1" fontWeight="700" color={tone(p).ink}>
{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="$hover" 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>
)
}
/** Where a card's app lives, as a reader types it: `hanzo.ai/chat`, `hanzo.bot`. The name links there; Explore opens the product's page (`page`). */
const address = (href: string) => (href.startsWith('/') ? `hanzo.ai${href}` : href.replace(/^https?:\/\//, '').replace(/\/$/, ''))
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)' })
const face = useLean(still)
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
ref={face}
data-tilt=""
position="relative"
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' && <ChatCard play={play} still={still} />}
{p.key === 'dev' && <DevCard play={play} still={still} />}
{p.key === 'bot' && <BotCard play={play} still={still} />}
{p.key === 'studio' && <StudioCard play={play} still={still} />}
{p.key === 'team' && <TeamPreview play={play} still={still} />}
<XStack px={22} py={16} gap={12} items="center" justify="space-between">
<YStack
render={<a href={p.href} />}
data-app=""
gap={2}
minW={0}
aria-label={t('Open {name} at {address}', { name: p.tag, address: address(p.href) })}
hoverStyle={{ opacity: 0.85 }}
$platform-web={{ textDecoration: 'none' }}
>
<Text fontSize="$5" lineHeight="$5" fontWeight="600" color="var(--foreground)">
{p.name}
</Text>
<Text fontFamily="$mono" fontSize="$1" lineHeight="$1" color="var(--muted-foreground)" data-i18n-ltr="">
{address(p.href)}
</Text>
</YStack>
<XStack
render={<a href={p.page} />}
data-page=""
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>
)
}