Files
hanzo.ai/components/enso/Runtime.tsx

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>
)
}