mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
367 lines
12 KiB
TypeScript
367 lines
12 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* The /enso hero: one request, the runtime that weighs it, the lane it takes.
|
|
*
|
|
* A request comes in; Enso weighs it on quality, latency, cost and policy;
|
|
* it takes one of three lanes — write, decide, act — and a result comes out
|
|
* with the reason. The resting frame is the page: the opening request already
|
|
* routed, so a reader with scripts off or motion reduced sees a whole choice.
|
|
* When motion runs, the request slides in, the four weights light in turn, a
|
|
* signal travels the path, and the lane that answers takes it.
|
|
*
|
|
* Phone: one column, the lanes stacked. From 768: the lanes side by side, so
|
|
* the branch reads as a branch. The lanes and the requests belong to the page.
|
|
* Every box that holds words is filled, because the hero's dot field runs
|
|
* behind the figure.
|
|
*/
|
|
|
|
import { useLayoutEffect, useRef, useState } from 'react'
|
|
import { Box, Text, View, XStack, YStack } from '@hanzo/ui'
|
|
import { useCycle } from './cycle'
|
|
import { EASE, TIME } from './motion'
|
|
import type { Lane, Scene } from './scenes'
|
|
|
|
const AXES = ['Quality', 'Latency', 'Cost', 'Policy'] as const
|
|
const RING = 2 * Math.PI * 6.5
|
|
|
|
/** A stem long enough that the signal has a path to travel, not a join to hop. */
|
|
function Stem() {
|
|
return <View self="center" width={1} height={32} bg="var(--border)" />
|
|
}
|
|
|
|
/** The centre of an element, in the root's own coordinates. */
|
|
function centre(root: DOMRect, el: Element) {
|
|
const r = el.getBoundingClientRect()
|
|
return { left: r.left - root.left + r.width / 2, top: r.top - root.top + r.height / 2 }
|
|
}
|
|
|
|
export default function Runtime({
|
|
lanes,
|
|
scenes,
|
|
rest,
|
|
}: {
|
|
lanes: readonly Lane[]
|
|
scenes: readonly Scene[]
|
|
/** The request the page opens on. */
|
|
rest: number
|
|
}) {
|
|
const { i, phase, play } = useCycle('enso-runtime', scenes.length, rest)
|
|
const [live, setLive] = useState(false)
|
|
const [leaving, setLeaving] = useState<string | null>(null)
|
|
const [axis, setAxis] = useState(-1)
|
|
const seen = useRef(scenes[rest]?.request ?? '')
|
|
|
|
const scene = scenes[i] ?? scenes[0]
|
|
const lane = lanes.find((l) => l.id === scene?.lane)
|
|
const weighing = play && phase === 'weigh'
|
|
|
|
useLayoutEffect(() => {
|
|
if (!play || !scene || seen.current === scene.request) return
|
|
setLeaving(seen.current)
|
|
seen.current = scene.request
|
|
setLive(true)
|
|
}, [play, scene])
|
|
|
|
// The request line: the next one slides up into its clip as the last leaves.
|
|
useLayoutEffect(() => {
|
|
if (!leaving) return
|
|
const box = document.getElementById('enso-request')
|
|
const next = box?.querySelector('[data-next]')
|
|
const prev = box?.querySelector('[data-prev]')
|
|
if (!(next instanceof HTMLElement)) return
|
|
|
|
const slide = (el: HTMLElement, from: string, to: string) => {
|
|
el.style.transition = 'none'
|
|
el.style.transform = from
|
|
return () => {
|
|
el.style.transition = `transform ${TIME.swap}ms ${EASE}`
|
|
el.style.transform = to
|
|
}
|
|
}
|
|
const goNext = slide(next, 'translateY(110%)', 'translateY(0)')
|
|
const goPrev = prev instanceof HTMLElement ? slide(prev, 'translateY(0)', 'translateY(-110%)') : null
|
|
|
|
let raf = 0
|
|
let closed = false
|
|
const done = () => {
|
|
if (closed) return
|
|
closed = true
|
|
next.style.transition = 'none'
|
|
next.style.transform = ''
|
|
setLeaving(null)
|
|
}
|
|
const onEnd = (event: TransitionEvent) => {
|
|
if (event.target === next && event.propertyName === 'transform') done()
|
|
}
|
|
next.addEventListener('transitionend', onEnd)
|
|
raf = window.requestAnimationFrame(() => {
|
|
raf = window.requestAnimationFrame(() => {
|
|
goNext()
|
|
goPrev?.()
|
|
})
|
|
})
|
|
const backup = window.setTimeout(done, TIME.swap + 80)
|
|
return () => {
|
|
closed = true
|
|
window.cancelAnimationFrame(raf)
|
|
window.clearTimeout(backup)
|
|
next.removeEventListener('transitionend', onEnd)
|
|
next.style.transition = 'none'
|
|
next.style.transform = ''
|
|
}
|
|
}, [leaving])
|
|
|
|
// The four weights light one after another while the request is weighed.
|
|
useLayoutEffect(() => {
|
|
if (!weighing) return
|
|
let n = -1
|
|
let timer = 0
|
|
const tick = () => {
|
|
n += 1
|
|
setAxis(n)
|
|
if (n >= AXES.length - 1) return
|
|
timer = window.setTimeout(tick, TIME.weigh / AXES.length)
|
|
}
|
|
timer = window.setTimeout(tick, 0)
|
|
return () => window.clearTimeout(timer)
|
|
}, [weighing, i])
|
|
|
|
// The signal: request → Enso → the lane that answers → the result. The lanes
|
|
// sit side by side from 768, so the path moves across as well as down.
|
|
useLayoutEffect(() => {
|
|
if (!play || phase !== 'pick') return
|
|
const root = document.getElementById('enso-runtime')
|
|
const dot = root?.querySelector('[data-dot]')
|
|
if (!root || !(dot instanceof HTMLElement)) return
|
|
const box = root.getBoundingClientRect()
|
|
const stops = ['request', 'enso', 'lane', 'result']
|
|
.map((name) => root.querySelector(`[data-stop="${name}"]`))
|
|
.filter((el): el is Element => el != null)
|
|
.map((el) => centre(box, el))
|
|
if (stops.length < 2) return
|
|
const travel = dot.animate(
|
|
// Faint as it leaves the request, gone as it reaches the result, so it never rests on the reason.
|
|
stops.map((p, n) => ({ left: `${p.left}px`, top: `${p.top}px`, opacity: n === 0 ? 0.15 : n === stops.length - 1 ? 0 : 1 })),
|
|
{ duration: TIME.travel, easing: EASE, fill: 'forwards' },
|
|
)
|
|
return () => travel.cancel()
|
|
}, [play, phase, i])
|
|
|
|
useLayoutEffect(() => {
|
|
if (!play) return
|
|
const ring = document.querySelector('#enso-runtime [data-ring]')
|
|
if (!(ring instanceof SVGElement)) return
|
|
ring.style.strokeDasharray = `${RING}`
|
|
const draw = ring.animate([{ strokeDashoffset: RING }, { strokeDashoffset: 0 }], {
|
|
duration: 900,
|
|
easing: EASE,
|
|
fill: 'forwards',
|
|
})
|
|
return () => {
|
|
draw.cancel()
|
|
ring.style.strokeDasharray = ''
|
|
ring.style.strokeDashoffset = ''
|
|
}
|
|
}, [play])
|
|
|
|
return (
|
|
<Box
|
|
id="enso-runtime"
|
|
position="relative"
|
|
display="flex"
|
|
flexDirection="column"
|
|
width="100%"
|
|
role="figure"
|
|
aria-label="Enso routing a request to the lane that answers it"
|
|
>
|
|
<Text
|
|
position="absolute"
|
|
width={1}
|
|
height={1}
|
|
overflow="hidden"
|
|
aria-live="polite"
|
|
$platform-web={{ clip: 'rect(0 0 0 0)' }}
|
|
>
|
|
{live && lane && scene ? `${scene.request} Enso routes it to ${lane.verb.toLowerCase()}: ${scene.name}.` : ''}
|
|
</Text>
|
|
|
|
{play ? (
|
|
<View
|
|
position="absolute"
|
|
width={11}
|
|
height={11}
|
|
rounded={999}
|
|
bg="$ink"
|
|
pointerEvents="none"
|
|
z="var(--z-raised)"
|
|
opacity={0}
|
|
{...{ 'data-dot': '' }}
|
|
$platform-web={{ transform: 'translate(-50%, -50%)', top: 0, left: '50%' }}
|
|
/>
|
|
) : null}
|
|
|
|
<YStack
|
|
gap="$2"
|
|
px="$4"
|
|
py="$4"
|
|
borderWidth={1}
|
|
borderColor="var(--border)"
|
|
rounded="var(--radius-xl)"
|
|
bg="$panel"
|
|
{...{ 'data-stop': 'request' }}
|
|
>
|
|
<Text fontFamily="$mono" fontSize="$1" lineHeight="$1" color="var(--muted-foreground)">
|
|
Request
|
|
</Text>
|
|
<View id="enso-request" overflow="hidden" position="relative">
|
|
<Text fontSize="$5" lineHeight="$5" color="$ink" display="block" {...(leaving ? { 'data-next': '' } : {})}>
|
|
{scene?.request}
|
|
</Text>
|
|
{leaving ? (
|
|
<Text
|
|
aria-hidden
|
|
position="absolute"
|
|
t={0}
|
|
l={0}
|
|
width="100%"
|
|
fontSize="$5"
|
|
lineHeight="$5"
|
|
color="$ink"
|
|
display="block"
|
|
{...{ 'data-prev': '' }}
|
|
>
|
|
{leaving}
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
</YStack>
|
|
|
|
<Stem />
|
|
|
|
<YStack self="center" items="center" gap="$3" {...{ 'data-stop': 'enso' }}>
|
|
<XStack
|
|
items="center"
|
|
gap="$3"
|
|
px="$4"
|
|
py="$3"
|
|
borderWidth={1}
|
|
borderColor="var(--border-strong)"
|
|
rounded="var(--radius-full)"
|
|
bg="$background"
|
|
>
|
|
<View width={18} height={18} shrink={0}>
|
|
<svg viewBox="0 0 18 18" width="18" height="18" aria-hidden>
|
|
<circle cx="9" cy="9" r="6.5" fill="none" stroke="var(--foreground)" strokeWidth="1.25" data-ring="" />
|
|
</svg>
|
|
</View>
|
|
<Text fontSize="$3" lineHeight="$3" fontWeight="600" color="$ink">
|
|
Enso
|
|
</Text>
|
|
<Text fontFamily="$mono" fontSize="$1" lineHeight="$1" color="var(--muted-foreground)">
|
|
understand · constrain · route
|
|
</Text>
|
|
</XStack>
|
|
<XStack role="list" aria-label="What Enso weighs" flexWrap="wrap" justify="center" gap="$2">
|
|
{AXES.map((name, n) => {
|
|
const lit = weighing && axis >= 0 && n <= axis
|
|
return (
|
|
<Text
|
|
key={name}
|
|
role="listitem"
|
|
fontFamily="$mono"
|
|
fontSize="$1"
|
|
lineHeight="$1"
|
|
px="$2"
|
|
py="$1"
|
|
borderWidth={1}
|
|
borderColor={lit ? 'var(--foreground)' : 'var(--border)'}
|
|
rounded="var(--radius-full)"
|
|
bg="$background"
|
|
color={lit ? '$ink' : 'var(--muted-foreground)'}
|
|
$platform-web={{ transition: `color ${TIME.row}ms ${EASE}, border-color ${TIME.row}ms ${EASE}` }}
|
|
>
|
|
{name}
|
|
</Text>
|
|
)
|
|
})}
|
|
</XStack>
|
|
</YStack>
|
|
|
|
<Stem />
|
|
|
|
<XStack role="list" aria-label="Lanes" gap="$2" width="100%" flexDirection="column" $md={{ flexDirection: 'row' }}>
|
|
{lanes.map((l) => {
|
|
const on = !weighing && l.id === scene?.lane
|
|
return (
|
|
<YStack
|
|
key={l.id}
|
|
role="listitem"
|
|
aria-current={on ? 'true' : undefined}
|
|
minW={0}
|
|
$md={{ flex: 1 }}
|
|
gap="$1"
|
|
px="$3"
|
|
py="$3"
|
|
borderWidth={1}
|
|
borderColor={on ? 'var(--foreground)' : 'var(--border)'}
|
|
borderStyle={l.soon ? 'dashed' : 'solid'}
|
|
rounded="var(--radius-xl)"
|
|
bg={on ? '$hover' : '$panel'}
|
|
$platform-web={{ transition: `border-color ${TIME.row}ms ${EASE}, background-color ${TIME.row}ms ${EASE}` }}
|
|
{...(on ? { 'data-stop': 'lane' } : {})}
|
|
>
|
|
<XStack justify="space-between" items="baseline" gap="$2" flexWrap="wrap">
|
|
<Text fontSize="$4" lineHeight="$4" fontWeight="600" color={on ? '$ink' : 'var(--text-secondary)'}>
|
|
{l.verb}
|
|
</Text>
|
|
{l.soon ? (
|
|
<Text fontFamily="$mono" fontSize="$1" lineHeight="$1" color="var(--muted-foreground)" $platform-web={{ whiteSpace: 'nowrap' }}>
|
|
{l.soon}
|
|
</Text>
|
|
) : null}
|
|
</XStack>
|
|
<Text fontSize="$2" lineHeight="$2" color="var(--muted-foreground)">
|
|
{l.who}
|
|
</Text>
|
|
<View
|
|
height={1}
|
|
mt="$1"
|
|
bg="$ink"
|
|
$platform-web={{
|
|
transform: on ? 'scaleX(1)' : 'scaleX(0)',
|
|
transformOrigin: 'left center',
|
|
transition: play ? `transform ${TIME.swap}ms ${EASE}` : undefined,
|
|
}}
|
|
/>
|
|
</YStack>
|
|
)
|
|
})}
|
|
</XStack>
|
|
|
|
<Stem />
|
|
|
|
<YStack
|
|
gap="$2"
|
|
px="$4"
|
|
py="$4"
|
|
borderWidth={1}
|
|
borderColor="var(--border)"
|
|
rounded="var(--radius-xl)"
|
|
bg="$panel"
|
|
{...{ 'data-stop': 'result' }}
|
|
>
|
|
<Text fontFamily="$mono" fontSize="$1" lineHeight="$1" color="var(--muted-foreground)">
|
|
Result{lane ? ` · ${lane.verb}` : ''}
|
|
</Text>
|
|
<Text fontSize="$4" lineHeight="$4" fontWeight="600" color="$ink">
|
|
{scene?.name}
|
|
</Text>
|
|
<Text fontSize="$2" lineHeight="$2" color="var(--text-secondary)">
|
|
{scene?.why}
|
|
</Text>
|
|
</YStack>
|
|
</Box>
|
|
)
|
|
}
|