mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
Every import, the catalog pin and the docs name @hanzo/app and github.com/hanzoai/app. lib/rooms.ts is lib/app.ts and components/rooms.tsx is components/app.tsx.
482 lines
16 KiB
TypeScript
482 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'
|
|
|
|
/**
|
|
* 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()
|
|
|
|
// 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={`${name} in the console`}
|
|
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}
|
|
ml="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 ? `Open ${name}` : 'Open in the console'}
|
|
</Text>
|
|
<ArrowUpRight size={14} color="var(--foreground)" />
|
|
</XStack>
|
|
</XStack>
|
|
</YStack>
|
|
|
|
<Text render="figcaption" display="block" mt={10} mx={2} fontSize="$1" lineHeight="$1" color="var(--text-tertiary)">
|
|
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 }) {
|
|
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)">
|
|
{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)"
|
|
>
|
|
{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 }) {
|
|
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)}
|
|
>
|
|
{turn.text}
|
|
</Step>
|
|
</YStack>
|
|
)
|
|
})}
|
|
</YStack>
|
|
)
|
|
}
|
|
|
|
export default Surface
|