Files

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