Files
zeekayandGitHub 3df66c3990 i18n: /m* through /r* and the product kits draw their words through t (#70)
Pages from /machines to /risk, the model pages, and the kits they are made of
(page-kit, ProductLanding, LayerPage, Stack, the models kit, band's Head) mark
authored copy where it is written and draw it in the reader's language. A server
page marks a literal phrase('…') or wraps it in <T>; a client page calls t. The
kits translate a string prop through useSay (components/marketing/say), so a
page passes phrase('…') and markup passes as written. An arrow that points
turns over right to left (data-i18n-mirror).

Rebased on main: the fold-based heroes (Fold, PageHero, MachinesHero, the
product and layer pages) keep main's structure and copy, with their label,
title and lede marked phrase. /platform is main's CloudLanding; the deleted
/platform/layers and the old referral components stay deleted.

Claude-Session: https://claude.ai/code/session_015XYKLjZtDTJEnMo4eK3AFH
2026-10-30 00:18:44 -07:00

486 lines
16 KiB
TypeScript

'use client'
import { useEffect, useState, type ComponentProps, type ReactNode } from 'react'
import Link from 'next/link'
import { ArrowUpRight } from 'lucide-react'
import { Pulse, Text, View, XStack, YStack } from '@hanzo/ui'
import { Lights } from '@hanzo/app/bots/Lights'
import { cloudCategories } from '@/lib/data/cloud-primitives'
import { SERVICES, SESSION, SHELL } from '@/lib/data/example'
import { away } from '@hanzo/app/lib/host'
import { useHost } from '@hanzo/app/lib/hostname'
import { useI18n } from '@hanzo/i18n/react'
/**
* The product's own surface, drawn.
*
* DRAWN IN THE PAGE'S OWN MATERIAL, not screenshotted and not filmed. A raster
* of an app is wrong in the other theme the day it lands and stale the week
* after; it answers no screen reader, translates into nothing, and reflows at
* no width. This is the same tokens, borders and type as the rest of the site,
* so it stays correct when the theme flips and moves when the design system
* moves.
*
* EVERYTHING WITH A FACT IN IT IS READ, NOT WRITTEN. The rail is the product's
* own corner of the console — its category's leaves, in catalog order, each
* wearing the mark the catalog names and linking to the page it resolves to.
* The route in the footer is the one the catalog states. What is invented is
* the WORK: one example project, held in lib/data/example, plainly the reader's
* own and carrying no measurement.
*
* IT IS A CONTROL, not a picture. Pointing at a row selects it, clicking one
* keeps it, and the rail navigates. A reader who wants the real thing opens it
* from the footer.
*
* STYLED HERE, in gui props over @hanzo/design's tokens. What answers a pointer
* also answers a key: a rail leaf is a gui `group`, and the name it offers
* shows on the group's hover AND its focus-visible, so Tab reaches what a
* pointer does. The `data-slot` names carry no rule; they are what
* e2e/gates/surface.spec.ts reads.
*/
/** The three shapes a console body takes. */
export type Shape = 'list' | 'shell' | 'chat'
/**
* Which shape a product's console takes.
*
* Unlisted products are a list of resources, which is what most of a console
* is — a product added to the catalog tomorrow draws as one rather than as
* nothing.
*/
const SHAPE: Record<string, Shape> = {
cli: 'shell', api: 'shell', sdks: 'shell', ide: 'shell',
builds: 'shell', crawl: 'shell', logs: 'shell', functions: 'shell',
agents: 'chat', bot: 'chat', chat: 'chat', desktop: 'chat',
inference: 'chat', playground: 'chat', prompts: 'chat', sessions: 'chat',
}
/**
* The rooms this site serves under app/(app). A product with a room of its own
* opens there; everything else opens the workspace that holds every room.
*/
const ROOM = new Set([
'beluga', 'board', 'bots', 'cal', 'chat', 'contacts',
'dev', 'drive', 'inbox', 'meet', 'vibe', 'work',
])
/** How long one step of the example holds before the next arrives. */
const BEAT = 2600
/** How many leaves of a category the rail shows before it runs out of height. */
const RAIL = 8
/** The hairline every part of the window is ruled with. */
const EDGE = { borderColor: '$edge', borderStyle: 'solid' } as const
/** What a step does when a pointer or a key reaches it: its ink comes up to full. */
const REACHED = { color: '$ink' } as const
/**
* Whether the example runs. False in the exported file and until hydration,
* and false for a reader who asked for no motion — so the file and a still page
* both show every step, and only a moving surface holds the next ones back.
*/
function useLive() {
const [live, setLive] = useState(false)
useEffect(() => setLive(!matchMedia('(prefers-reduced-motion: reduce)').matches), [])
return live
}
export function Surface({
name,
api,
rail,
here,
shape,
}: {
/** The product, as the titlebar names it. */
name: string
/** The route the catalog states for this product, when it states one. */
api?: string
/** Category id — which corner of the console the rail shows. */
rail: string
/** Catalog slug of the leaf the rail marks as current. */
here?: string
/** The body. Derived from the product when absent. */
shape?: Shape
}) {
const body = shape ?? (here ? SHAPE[here] : undefined) ?? 'list'
const steps = body === 'list' ? SERVICES.length : body === 'shell' ? SHELL.length : SESSION.length
const [at, setAt] = useState(0)
const [over, setOver] = useState(false)
const [picked, setPicked] = useState(false)
const host = useHost()
const live = useLive()
const { t } = useI18n()
// A pointer on the surface pauses it, and a click keeps the step the reader
// chose. A still surface never starts the timer.
useEffect(() => {
if (!live || over || picked) return
const timer = setInterval(() => setAt((i) => (i + 1) % steps), BEAT)
return () => clearInterval(timer)
}, [live, over, picked, steps])
const pick = (i: number) => {
setAt(i)
setPicked(true)
}
// The body owns how many steps it has, so a surface whose shape changes under
// it reads inside the new one rather than off the end of the old.
const step = at % steps
const room = here && ROOM.has(here) ? `/${here}` : null
// On the apex a workspace room is hanzo.team's, so the link names it there.
const open = room ?? '/home'
const opens = away(host, open) ?? open
// The rail is a WINDOW on the category, anchored so the product a reader is
// looking at is always on it — Observe holds seventeen leaves and a plain
// `slice` would draw the console's rail without the product it is drawn for.
const all = cloudCategories.find((c) => c.id === rail)?.items ?? []
const cursor = all.findIndex((one) => one.slug === here)
const from = Math.min(Math.max(0, cursor - 3), Math.max(0, all.length - RAIL))
const kin = all.slice(from, from + RAIL)
return (
<YStack
render="figure"
data-slot="surface"
m={0}
width="100%"
onPointerEnter={() => setOver(true)}
onPointerLeave={() => setOver(false)}
>
<YStack
data-slot="surface-window"
width="100%"
borderWidth={1}
{...EDGE}
rounded="var(--radius-lg)"
bg="var(--surface-header)"
backdropFilter="blur(18px)"
boxShadow="var(--shadow-floating)"
overflow="hidden"
>
<XStack items="center" gap={12} height={36} px={12} bg="var(--glass)" borderBottomWidth={1} {...EDGE}>
<Lights />
<Text fontFamily="$mono" fontSize="$1" color="var(--text-tertiary)">
{name} — Hanzo
</Text>
</XStack>
<XStack minH={300} $max-sm={{ minH: 260 }}>
{/* The rail is real: every leaf is the page it resolves to, and its
name arrives when a pointer or a key asks — which is what the
console does and what keeps the eye on the work. */}
<YStack
render="nav"
aria-label={t('{name} in the console', { name })}
gap={2}
width={46}
py={10}
borderRightWidth={1}
{...EDGE}
$max-sm={{ width: 40 }}
>
{kin.map((one) => {
const Mark = one.icon
const on = one.slug === here
return (
<View
key={one.slug}
render={<Link href={one.href} aria-current={on ? 'page' : undefined} />}
group
position="relative"
items="center"
justify="center"
height={34}
bg={on ? 'var(--glass)' : 'transparent'}
>
<Text
display="flex"
color={on ? '$ink' : 'var(--text-tertiary)'}
transition="quickest"
$group-hover={REACHED}
$group-focusVisible={REACHED}
>
<Mark size={15} />
</Text>
<Text
render="em"
position="absolute"
l="calc(100% + 8px)"
py={3}
px={8}
borderWidth={1}
{...EDGE}
rounded="var(--radius-sm)"
bg="var(--card)"
fontFamily="$mono"
fontSize="0.625rem"
fontStyle="normal"
letterSpacing={0.4}
whiteSpace="nowrap"
color="$ink"
pointerEvents="none"
z="var(--z-raised)"
opacity={0}
transition="quickest"
$group-hover={{ opacity: 1 }}
$group-focusVisible={{ opacity: 1 }}
>
{one.title}
</Text>
</View>
)
})}
</YStack>
<YStack flex={1} minW={0} py={16} px={18} $max-sm={{ p: 12 }}>
{body === 'list' && <List at={step} pick={pick} />}
{body === 'shell' && <Shell at={step} pick={pick} live={live} />}
{body === 'chat' && <Chat at={step} pick={pick} live={live} />}
</YStack>
</XStack>
<XStack items="center" justify="space-between" gap={12} py={10} px={14} bg="var(--glass)" borderTopWidth={1} {...EDGE} $max-sm={{ px: 12 }}>
{/* The route is the first thing to go on a phone: the control beside
it is the one a thumb is reaching for. */}
{api && (
<Text
render="code"
display="block"
shrink={1}
minW={0}
fontFamily="$mono"
fontSize="$1"
color="var(--text-tertiary)"
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
$max-sm={{ display: 'none' }}
>
api.hanzo.ai{api}
</Text>
)}
{/* The route beside it is optional — a product the catalog states no
path for draws none — so the control claims the right edge itself. */}
<XStack
render={<Link href={opens} />}
items="center"
gap={6}
marginInlineStart="auto"
minH={28}
px={12}
borderWidth={1}
{...EDGE}
rounded="var(--radius-full)"
hoverStyle={{ borderColor: 'var(--muted-foreground)', bg: 'var(--glass)' }}
focusVisibleStyle={{ borderColor: 'var(--muted-foreground)', bg: 'var(--glass)' }}
>
<Text fontSize="$1" fontWeight="500" whiteSpace="nowrap" color="$ink">
{room ? t('Open {name}', { name }) : t('Open in the console')}
</Text>
<ArrowUpRight size={14} color="var(--foreground)" data-i18n-mirror />
</XStack>
</XStack>
</YStack>
<Text render="figcaption" display="block" mt={10} mx={2} fontSize="$1" lineHeight="$1" color="var(--text-tertiary)">
{t('Example project. Every name in it is one you would give your own.')}
</Text>
</YStack>
)
}
/** What the project has running — the shape most of the console takes. */
function List({ at, pick }: { at: number; pick: (i: number) => void }) {
const { t } = useI18n()
return (
<>
<YStack render="ul" flex={1} gap={2} m={0} p={0}>
{SERVICES.map((s, i) => {
const on = i === at
const ink = on ? '$ink' : 'var(--muted-foreground)'
return (
<YStack render="li" key={s.name}>
<View
render={<button type="button" />}
data-slot="surface-row"
data-on={on ? '' : undefined}
aria-current={on}
onPointerEnter={() => pick(i)}
onFocus={() => pick(i)}
onClick={() => pick(i)}
display="grid"
gridTemplateColumns="14px 1fr auto"
items="center"
gap={12}
width="100%"
py={9}
px={10}
rounded="var(--radius-md)"
bg={on ? 'var(--glass)' : 'transparent'}
cursor="pointer"
>
{/* A ring for a state at rest, a filled ring that breathes for one still moving. */}
{s.state === 'Building' ? (
<Pulse width={8} height={8} ml={3} rounded="var(--radius-full)" borderWidth={1} borderColor={ink} bg={ink} />
) : (
<View width={8} height={8} ml={3} rounded="var(--radius-full)" borderWidth={1} borderColor={ink} />
)}
<Text
data-slot="surface-name"
fontFamily="$mono"
fontSize="$2"
color={ink}
text="left"
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{s.name}
</Text>
<Text fontSize="$1" color="var(--text-tertiary)">
{t(s.state)}
</Text>
</View>
</YStack>
)
})}
</YStack>
{/* One strip under the list rather than a row that grows: a detail that
opens in place moves every row beneath it under the reader's pointer. */}
<Text
render="p"
display="block"
mt={12}
minH="calc(2.8em)"
pt={12}
borderTopWidth={1}
{...EDGE}
fontSize="$2"
lineHeight="$3"
color="var(--muted-foreground)"
>
{t(SERVICES[at].note)}
</Text>
</>
)
}
/**
* One step of a transcript, as a button: its ink comes up to full when a
* pointer or a key reaches it. A step the surface has not reached yet is held
* at a trace, by opacity alone, so nothing reflows and the block below the
* window never moves.
*/
function Step({
slot,
ink,
dim,
pick,
children,
...type
}: {
slot: 'surface-line' | 'surface-turn'
ink: ComponentProps<typeof Text>['color']
dim: boolean
pick: () => void
children: ReactNode
} & ComponentProps<typeof Text>) {
return (
<Text
render={<button type="button" />}
data-slot={slot}
onPointerEnter={pick}
onFocus={pick}
onClick={pick}
display="block"
width="100%"
py={4}
px={2}
text="left"
color={ink}
opacity={dim ? 0.12 : 1}
cursor="pointer"
transition="quick"
hoverStyle={REACHED}
focusVisibleStyle={REACHED}
{...type}
>
{children}
</Text>
)
}
/** The same work at a prompt. */
function Shell({ at, pick, live }: { at: number; pick: (i: number) => void; live: boolean }) {
return (
<YStack render="ol" flex={1} gap={4} m={0} p={0}>
{SHELL.map(([sigil, text], i) => (
<YStack render="li" key={text}>
<Step
slot="surface-line"
data-next={i > at ? '' : undefined}
ink="var(--muted-foreground)"
dim={live && i > at}
pick={() => pick(i)}
fontFamily="$mono"
fontSize="$2"
lineHeight="$4"
>
<Text pr={10} fontWeight="400" color="var(--text-tertiary)">
{sigil}
</Text>
{text}
</Step>
</YStack>
))}
</YStack>
)
}
/**
* The same work as a conversation. Who is speaking is said by weight and
* indent, not by two bubbles in two tints: one ink, and the reader still knows.
*/
function Chat({ at, pick, live }: { at: number; pick: (i: number) => void; live: boolean }) {
const { t } = useI18n()
return (
<YStack render="ol" flex={1} gap={14} m={0} p={0}>
{SESSION.map((turn, i) => {
const you = turn.from === 'you'
const enso = turn.from === 'enso'
return (
<YStack render="li" key={turn.text}>
<Step
slot="surface-turn"
data-who={turn.from}
data-next={i > at ? '' : undefined}
ink={you ? '$ink' : 'var(--muted-foreground)'}
dim={live && i > at}
pick={() => pick(i)}
fontSize="$2"
lineHeight="$3"
maxW="min(52ch, 100%)"
fontWeight={you ? '500' : undefined}
pl={enso ? 14 : undefined}
borderLeftWidth={enso ? 1 : undefined}
{...(enso ? EDGE : null)}
>
{t(turn.text)}
</Step>
</YStack>
)
})}
</YStack>
)
}
export default Surface