mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
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.
495 lines
20 KiB
TypeScript
495 lines
20 KiB
TypeScript
'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 “{t('Require a token on every API route')}”
|
||
</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>
|
||
)
|
||
}
|