Files
hanzo.ai/components/home/chrome.tsx
zeekay 0f5536b707 hanzo.ai reads in the reader's language, and Arabic right to left
Every word the landing, the header menus, the phone sheet, the footer,
the help corner and the way in (sign-in, sign-up, the reset, the
invitation, the callback, onboarding, the 404) write goes through
@hanzo/i18n's t(): one sentence is one message, its figures, names and
links arguments; copy written as data is phrase('…') where written and
t(label) where drawn. Plans, the chart, the API states and the lines
lib/kai and lib/enso build for the landing do the same.

Right to left: <html lang dir> is set before the first paint (the
package's bootScript first in the head); a figure, clock, range or
price outside a sentence is isolated; sides are logical gui props, the
layout's [data-join] corners logical, a bar grows from the start;
arrows along the line mirror (Onward for "words ->" links). The repo
dates read in the reader's language.

Gates select by slot, never by an English label (rtl, help). New gate
translated.spec.ts: in Arabic the landing at 1440 and 390, every menu
open, the phone sheet, sign-in, sign-up and the app signed in hold no
authored English, nothing scrolls sideways, every range is isolated;
French the same for English's commonest words. What another package
draws with no words prop is listed by name (@hanzo/ui's Footer label,
@hanzo/build's composer row and suggestions).

@hanzo/i18n 0.5.3 (the catalogs: 1,443 keys, every offered locale
complete), @hanzo/app 0.2.1 (the app's words). The link gate imports
the terms versions from @hanzo/app, the one SDK main now resolves.
2026-10-22 19:54:31 -07:00

373 lines
14 KiB
TypeScript

'use client'
import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode } from 'react'
import { useI18n } from '@hanzo/i18n/react'
import { Text, View, XStack, YStack, type GuiElement } from '@hanzo/ui'
import { Dots, type Field } from '@hanzo/ui/dots'
import { Lede, Title } from '@hanzo/ui/marketing'
import { Label } from './label'
/**
* The parts every landing section is drawn from: a window, the dot field behind
* it, the section's head, and the two facts an animation asks before it moves
* (is it on screen, and does the reader want motion).
*
* MOTION MOVES MARKS, NEVER COPY. A bar, a dot, a line and a clock animate; a
* word, a figure's text and a heading are drawn where they stand from the first
* frame, so a reader the observer never reaches still reads every one
* (LLM.md "A scroll reveal may not hide the content it reveals").
*/
const REDUCE = '(prefers-reduced-motion: reduce)'
const listen = (on: () => void) => {
const q = window.matchMedia(REDUCE)
q.addEventListener('change', on)
return () => q.removeEventListener('change', on)
}
/** Whether the reader asked for less motion. The server and the first paint assume still, so nothing moves before the page knows. */
export function useStill(): boolean {
return useSyncExternalStore(
listen,
() => window.matchMedia(REDUCE).matches,
() => true,
)
}
/**
* The element `ref` names has come on screen (a fifth of it, by default): true
* once and for good, so an animation plays the first time it is seen and holds
* its last frame after.
*/
export function useSeen(threshold = 0.2) {
const ref = useRef<GuiElement>(null)
const [seen, setSeen] = useState(false)
useEffect(() => {
const el = ref.current as unknown as Element | null
if (!el) return
if (typeof IntersectionObserver === 'undefined') return
const io = new IntersectionObserver(
([e]) => {
if (!e?.isIntersecting) return
setSeen(true)
io.disconnect()
},
{ threshold },
)
io.observe(el)
return () => io.disconnect()
}, [threshold])
return [ref, seen] as const
}
/** A DOM node behind a gui ref. */
export const node = (r: { current: GuiElement | null }) => r.current as unknown as HTMLElement | null
/**
* A mark that grows into place the first time it is seen: a paused animation
* from its first frame, so it waits at `from` with no style of its own, plays
* when `play` turns true, and stands at `to` for a reader who wants no motion.
*/
export function useGrow(play: boolean, still: boolean, { delay = 0, duration = 700, from = 'scaleX(0)', to = 'scaleX(1)' } = {}) {
const ref = useRef<GuiElement>(null)
const run = useRef<Animation | null>(null)
useLayoutEffect(() => {
const el = node(ref)
if (!el || typeof el.animate !== 'function') return
const a = el.animate([{ transform: from }, { transform: to }], { duration, delay, easing: 'cubic-bezier(0.2, 0.7, 0.2, 1)', fill: 'both' })
run.current = a
if (still) a.finish()
else a.pause()
return () => a.cancel()
}, [still, delay, duration, from, to])
useEffect(() => {
if (play && !still) run.current?.play()
}, [play, still])
return ref
}
/** Where a window may be moved: a screen this wide or wider. Narrower, the windows stay stacked where the page put them. */
const ROOMY = '(min-width: 1024px)'
const roomy = (on: () => void) => {
const q = window.matchMedia(ROOMY)
q.addEventListener('change', on)
return () => q.removeEventListener('change', on)
}
/** Whether the windows may move: false on the server and the first paint, so nothing moves before the page knows. */
function useRoomy(): boolean {
return useSyncExternalStore(
roomy,
() => window.matchMedia(ROOMY).matches,
() => false,
)
}
/** The window last taken hold of is drawn above the rest. One count for the page. */
let lifted = 1
/** The pixels an arrow key moves a focused title bar; with Shift, four times as far. */
const NUDGE = 12
/**
* A window: a title bar in the page's ink with the page's ground for its words,
* a mark or icon, a mono label, and whatever the window holds. Smooth rounded
* corners with subtle depth. The bar carries no control of its own.
*
* ON A WIDE SCREEN A WINDOW MOVES. Its title bar is the handle: a pointer or a
* finger drags it, and the arrow keys nudge it while the bar has focus. It
* moves by a transform from where the page put it, held inside the
* section it belongs to, so none is lost off the page, and the one taken hold
* of comes to the front. Below 1024px the windows stay stacked and still.
* Everything inside a window stays a control of its own: only the bar drags.
*/
export function Win({
title,
aside,
icon,
children,
slot,
open,
}: {
title: string
aside?: ReactNode
icon?: ReactNode
children: ReactNode
slot?: string
/** Lets a menu inside spill past the frame; the bar then rounds its own top corners. */
open?: boolean
}) {
const { t } = useI18n()
const box = useRef<GuiElement>(null)
const wide = useRoomy()
const [at, setAt] = useState({ x: 0, y: 0 })
const [z, setZ] = useState(0)
const grab = useRef<{ id: number; x: number; y: number; from: { x: number; y: number } } | null>(null)
// Back where the page put it when the screen narrows past moving.
useEffect(() => {
if (!wide) setAt({ x: 0, y: 0 })
}, [wide])
/** An offset held inside the section the window belongs to. */
const held = (x: number, y: number) => {
const el = node(box)
const room = el?.closest('section')
if (!el || !room) return { x, y }
const r = el.getBoundingClientRect()
const c = room.getBoundingClientRect()
// Where the window stands with no offset, so the bounds do not move with it.
const left = r.left - at.x
const top = r.top - at.y
const clamp = (v: number, lo: number, hi: number) => (lo > hi ? 0 : Math.min(hi, Math.max(lo, v)))
return { x: clamp(x, c.left - left, c.right - left - r.width), y: clamp(y, c.top - top, c.bottom - top - r.height) }
}
const lift = () => setZ(++lifted)
const down = (e: React.PointerEvent<HTMLElement>) => {
if (!wide || (e.target as HTMLElement).closest('button')) return
lift()
grab.current = { id: e.pointerId, x: e.clientX, y: e.clientY, from: at }
e.currentTarget.setPointerCapture(e.pointerId)
e.preventDefault()
}
const move = (e: React.PointerEvent<HTMLElement>) => {
const g = grab.current
if (!g || g.id !== e.pointerId) return
setAt(held(g.from.x + e.clientX - g.x, g.from.y + e.clientY - g.y))
}
const up = (e: React.PointerEvent<HTMLElement>) => {
if (grab.current?.id === e.pointerId) grab.current = null
}
// gui types the event as React Native's; on the web it is the DOM's, which carries both fields.
const key = (e: { key?: string; shiftKey?: boolean; target: unknown; currentTarget: unknown; preventDefault: () => void }) => {
if (e.target !== e.currentTarget || !wide) return
const step = e.shiftKey ? NUDGE * 4 : NUDGE
const d = ({ ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] } as Record<string, number[]>)[e.key ?? '']
if (!d) return
e.preventDefault()
lift()
setAt(held(at.x + d[0], at.y + d[1]))
}
return (
<YStack
ref={box}
data-slot={slot}
data-win={title}
position="relative"
z={z || undefined}
minW={0}
borderWidth={1}
borderColor="var(--border-strong)"
bg="var(--background)"
rounded={20}
overflow={open ? 'visible' : 'hidden'}
boxShadow="0 28px 60px -32px color-mix(in srgb, var(--pure-black) 80%, transparent)"
$platform-web={{ borderRadius: '20px', ...(at.x || at.y ? { transform: `translate3d(${at.x}px, ${at.y}px, 0)` } : null) }}
>
<XStack
items="center"
gap={10}
px={12}
py={7}
bg="var(--foreground)"
borderTopLeftRadius={open ? 19 : undefined}
borderTopRightRadius={open ? 19 : undefined}
tabIndex={0}
role="group"
aria-label={wide ? t('{title} window. Drag or use the arrow keys to move it.', { title }) : t('{title} window', { title })}
data-handle=""
cursor={wide ? 'grab' : undefined}
focusVisibleStyle={{ outlineWidth: 2, outlineStyle: 'solid', outlineColor: 'var(--ring)', outlineOffset: -2 }}
onPointerDown={down}
onPointerMove={move}
onPointerUp={up}
onPointerCancel={up}
onKeyDown={key}
$platform-web={{ color: 'var(--background)', touchAction: wide ? 'none' : 'auto', userSelect: 'none' }}
>
<View aria-hidden display="flex" items="center" justify="center" shrink={0}>
{icon ?? <View width={6} height={6} rounded={9999} bg="var(--background)" $platform-web={{ borderRadius: '9999px' }} />}
</View>
<Text fontFamily="$mono" fontSize="$1" lineHeight="$1" color="var(--background)" fontWeight="600" numberOfLines={1} flex={1} minW={0}>
{title}
</Text>
{aside ? (
<XStack px={8} py={2} rounded={9999} borderWidth={1} borderColor="var(--background)" opacity={0.75} items="center" justify="center" $platform-web={{ borderRadius: '9999px' }}>
<Text fontFamily="$mono" fontSize="$1" lineHeight="$1" color="var(--background)" numberOfLines={1}>
{aside}
</Text>
</XStack>
) : null}
</XStack>
{children}
</YStack>
)
}
/** A line of the window's mono readout: a label at the start of the line, its value at the end. */
export function Reading({ label, children, strong }: { label: string; children: ReactNode; strong?: boolean }) {
return (
<XStack justify="space-between" flexWrap="wrap" columnGap={12} py={4}>
<Text fontFamily="$mono" fontSize="$2" lineHeight="$2" color="var(--muted-foreground)">
{label}
</Text>
<Text fontFamily="$mono" fontSize="$2" lineHeight="$2" color={strong ? 'var(--foreground)' : 'var(--muted-foreground)'} text="end" $platform-web={{ fontVariantNumeric: 'tabular-nums' }}>
{children}
</Text>
</XStack>
)
}
/**
* The dot field behind a section: a halftone of soft clouds, painted once at
* the cell pitch, and drifted as one composited layer while it is on screen.
* Moving the painted canvas, rather than repainting it, is what keeps the page
* at the display's rate: a transform costs the compositor, not the CPU.
*
* The clouds are placed against the words, so a right-to-left page, whose
* headings and promise stand at the right, draws the field mirrored
* (`data-i18n-mirror` on the frame; the drift moves the layer inside it).
*/
/** Clouds as centre and radius, in the field's 0..1 units. */
type Cloud = [number, number, number]
export const clouds =
(cs: Cloud[]): Field =>
(x, y) =>
Math.min(1, cs.reduce((v, [cx, cy, r]) => v + Math.exp(-((x - cx) ** 2 + (y - cy) ** 2) / (r * r)), 0)) ** 1.4
/** The sections': heaviest at the right and the foot, where no heading sits. */
const CLOUD = clouds([
[0.95, 0.35, 0.34],
[0.78, 0.98, 0.4],
[0.2, 1.05, 0.32],
])
export function Dither({ field = CLOUD, cell = 7, strength = 0.2 }: { field?: Field; cell?: number; strength?: number }) {
const ref = useRef<GuiElement>(null)
const still = useStill()
useEffect(() => {
const el = node(ref)
if (!el || still || typeof el.animate !== 'function') return
const drift = el.animate([{ transform: 'translate3d(0, 0, 0)' }, { transform: 'translate3d(-2.5%, 1.5%, 0)' }], {
duration: 26000,
direction: 'alternate',
iterations: Infinity,
easing: 'ease-in-out',
})
drift.pause()
const io = new IntersectionObserver(([e]) => (e?.isIntersecting ? drift.play() : drift.pause()))
io.observe(el)
return () => {
io.disconnect()
drift.cancel()
}
}, [still])
return (
<View aria-hidden position="absolute" t={0} l={0} r={0} b={0} overflow="hidden" pointerEvents="none" data-i18n-mirror>
<View ref={ref} position="absolute" t="-4%" l="-4%" width="108%" height="108%" opacity={strength} $platform-web={{ color: 'var(--foreground)' }}>
<Dots field={field} cell={cell} animate={false} fade={{ top: 0.12, bottom: 0.18, left: 0.06, right: 0.06 }} />
</View>
</View>
)
}
/** The first screen's field: clouds behind the figure and at the corners, clear behind the promise. */
const OPENING = clouds([
[0.98, 0.1, 0.36],
[0.7, 1.02, 0.34],
[0.02, 0.98, 0.26],
[0.62, 0.0, 0.18],
])
/**
* The dot field behind a page's first screen, denser than a section's. / opens
* on it, and so does /enso, the promise on the left and the figure on the right.
*/
export function Opening() {
return <Dither field={OPENING} strength={0.34} />
}
/**
* A section of the page: its anchor, a label in mono above the heading, the
* heading, a line under it, and the figure. Every section is a band of the
* page's measure, with the dot field behind it when it asks for one.
*/
export function Section({
id,
label,
title,
lede,
dither,
children,
}: {
id: string
label: string
title: ReactNode
lede?: ReactNode
dither?: boolean
children: ReactNode
}) {
return (
<View render="section" id={id} aria-labelledby={`${id}-title`} position="relative" width="100%" py={56} $md={{ py: 80 }}>
{dither ? <Dither /> : null}
<YStack position="relative" width="100%" maxW={1240} mx="auto" px="var(--page-gutter)" gap={40}>
<YStack gap={14} maxW={760}>
<Label>{label}</Label>
<Title id={`${id}-title`}>{title}</Title>
{lede ? <Lede>{lede}</Lede> : null}
</YStack>
{children}
</YStack>
</View>
)
}
/** The room a figure keeps for itself while its read is on its way, so nothing below it moves when it arrives. */
export function Hold({ height, children }: { height: number; children: ReactNode }) {
return (
<YStack minH={height}>
{children}
</YStack>
)
}