mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
93 lines
3.2 KiB
TypeScript
93 lines
3.2 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* "Enso knows when to write." — and the verb turns: decide, retrieve, act, route.
|
|
*
|
|
* The verbs are the runtime's lanes and its two steps around them, so the line
|
|
* says the figure in a sentence. A screen reader hears the whole sentence once;
|
|
* the turning word is hidden from it so it is not read again on every change.
|
|
* With motion reduced the sentence stands complete and still. Off screen or in a
|
|
* hidden tab the word waits.
|
|
*/
|
|
|
|
import { useEffect, useState, useSyncExternalStore } from 'react'
|
|
import { Text, View, XStack } from '@hanzo/ui'
|
|
import { EASE, TIME } from './motion'
|
|
|
|
const STILL = '(prefers-reduced-motion: reduce)'
|
|
|
|
/** Whether the word may turn: false on the server and under reduced motion, so the first paint is the still sentence. */
|
|
function useMoving() {
|
|
return useSyncExternalStore(
|
|
(change) => {
|
|
const query = window.matchMedia(STILL)
|
|
query.addEventListener('change', change)
|
|
return () => query.removeEventListener('change', change)
|
|
},
|
|
() => !window.matchMedia(STILL).matches,
|
|
() => false,
|
|
)
|
|
}
|
|
|
|
export function Words({ lead, verbs }: { lead: string; verbs: readonly string[] }) {
|
|
const [i, setI] = useState(0)
|
|
const play = useMoving()
|
|
|
|
useEffect(() => {
|
|
if (!play) return
|
|
const timer = window.setInterval(() => {
|
|
if (document.hidden) return
|
|
const node = document.getElementById('enso-words')
|
|
const r = node?.getBoundingClientRect()
|
|
if (!r || r.bottom < 0 || r.top > window.innerHeight) return
|
|
setI((n) => (n + 1) % verbs.length)
|
|
}, TIME.hold)
|
|
return () => window.clearInterval(timer)
|
|
}, [play, verbs.length])
|
|
|
|
const sentence = `${lead} ${verbs.slice(0, -1).join(', ')} and ${verbs.at(-1)}.`
|
|
|
|
return (
|
|
<XStack id="enso-words" items="center" gap="$2" flexWrap="wrap">
|
|
<Text position="absolute" width={1} height={1} overflow="hidden" $platform-web={{ clip: 'rect(0 0 0 0)' }}>
|
|
{sentence}
|
|
</Text>
|
|
<Text aria-hidden fontSize="$6" lineHeight="$6" color="var(--text-secondary)">
|
|
{lead}
|
|
</Text>
|
|
{play ? (
|
|
<View aria-hidden overflow="hidden" position="relative">
|
|
{/* The slot is as wide as the longest verb, so none is cut and the lead never shifts. */}
|
|
<Text fontSize="$6" lineHeight="$6" fontWeight="600" opacity={0} $platform-web={{ whiteSpace: 'nowrap' }}>
|
|
{verbs.reduce((a, b) => (b.length > a.length ? b : a))}.
|
|
</Text>
|
|
{verbs.map((verb, n) => (
|
|
<Text
|
|
key={verb}
|
|
fontSize="$6"
|
|
lineHeight="$6"
|
|
fontWeight="600"
|
|
color="$ink"
|
|
position="absolute"
|
|
t={0}
|
|
l={0}
|
|
$platform-web={{
|
|
whiteSpace: 'nowrap',
|
|
transform: `translateY(${(n - i) * 110}%)`,
|
|
opacity: n === i ? 1 : 0,
|
|
transition: `transform ${TIME.swap}ms ${EASE}, opacity ${TIME.swap}ms ${EASE}`,
|
|
}}
|
|
>
|
|
{verb}.
|
|
</Text>
|
|
))}
|
|
</View>
|
|
) : (
|
|
<Text aria-hidden fontSize="$6" lineHeight="$6" fontWeight="600" color="$ink">
|
|
{verbs.join(' · ')}.
|
|
</Text>
|
|
)}
|
|
</XStack>
|
|
)
|
|
}
|